From 26f957e084d6c8d2628ac6dbbf848ae4272434be Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 12:48:09 +0000 Subject: [PATCH] macros: a frontier no walk can reach is a fact about the map, not a window GO FRONTIER was 1,235 of the rung-10 run's macro starts in 47 minutes, over two museum floors and a town whose ground the fly had already covered. The tiles it aimed at were real and unreachable: 98 walkable tiles on the museum's ground floor, 62 of them reachable from the door, 39 never stood on and almost all of those behind the admission desk. A walk that can reach none of its goals refuses no route and writes every goal to the blocked ledger -- which is a ten brain minute window, so all of it came back and the refusal happened again, once per hold. A window is right for a target somebody is standing in front of and wrong for ground the map has fenced off. So the refusal is remembered per map instead, with no window, and it is cleared by the one event that can change the answer: the fly standing somewhere on that map it had not stood on before -- a door opened, a script carried it through, somebody moved out of a doorway. Re-entering the map clears nothing, which is the loop the window made. --- .../src/pokemon_red/macros/cartridge.rs | 87 ++++++++++++++++++- .../src/pokemon_red/macros/driver.rs | 82 +++++++++++++++-- .../src/pokemon_red/macros/executor.rs | 25 +++++- .../src/pokemon_red/macros/palette.rs | 10 +++ .../src/pokemon_red/macros/tests.rs | 79 +++++++++++++++++ .../flybrain-gb/src/pokemon_red/state.rs | 27 +++++- 6 files changed, 296 insertions(+), 14 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs index c2dd3c4..de90495 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs @@ -364,6 +364,14 @@ pub trait MacroState: GameState { false } + /// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable. + /// + /// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state + /// that cannot answer has proved nothing, which is a fresh session. + fn frontier_exhausted(&mut self) -> bool { + false + } + /// Whether the cartridge has pushed the fly off this tile of the loaded map. /// /// [`PushedLedger`] is the evidence and the measurement. The default is `false`: a state that @@ -628,9 +636,13 @@ impl StoodLedger for NoStood { pub struct Stood(std::collections::BTreeSet<(u8, Tile)>); impl Stood { - /// Record the tile the fly is standing on. Idempotent. - pub fn record(&mut self, map: u8, tile: Tile) { - self.0.insert((map, tile)); + /// Record the tile the fly is standing on, and say whether it is ground this run had not + /// stood on before. Idempotent. + /// + /// The answer is what clears a map's frontier mark ([`Frontiers`], section 12.14): new + /// ground under the fly is the one thing that can have changed which tiles it can reach. + pub fn record(&mut self, map: u8, tile: Tile) -> bool { + self.0.insert((map, tile)) } /// How much ground this session has watched, for a log line and the tests. @@ -649,6 +661,75 @@ impl StoodLedger for Stood { } } +/// Maps whose frontier the run has proved it cannot reach any more. +/// +/// **The rung-10 museum and Pewter City, 2026-09-22.** `GO FRONTIER` ran 1,235 times in 47 +/// minutes over two museum floors and a town whose walkable ground the fly had already covered. +/// The ground it was aiming at was real -- the museum's exhibit hall behind the admission desk, +/// the far side of a fence -- and unreachable: 98 walkable tiles on map `0x34`, 62 of them +/// reachable from the door, 39 never stood on and all but a handful of those behind the desk. +/// A walk that can reach none of its goals refuses `no route` and writes every one of them to +/// the blocked ledger ([`Targets`]), which is a **window**: ten brain minutes later all forty +/// tiles were candidates again, the button was back on the pad, and the refusal happened again. +/// A window is right for a target a person is standing in front of and wrong for ground the map +/// has fenced off. +/// +/// So the refusal is remembered per map instead. It is written when a `GO FRONTIER` refuses for +/// want of a route -- the measured fact "from here, no unstood tile of this map can be walked +/// to" -- and it is cleared the moment the fly **stands somewhere on that map it has not stood +/// before**, because that is the only thing that can have changed the answer: a door opened, a +/// script carried the fly through, somebody moved out of a doorway. Not on re-entering the map, +/// which is the loop the window made. +/// +/// Session state beside [`Talked`], [`Stood`], [`Areas`] and [`Pushed`], never checkpointed: a +/// restored run tries the frontier once more, which is the honest answer for a ledger that did +/// not survive. +pub trait FrontierLedger { + /// Whether a `GO FRONTIER` has proved this map's remaining frontier unreachable. + fn frontier_exhausted(&self, map: u8) -> bool; +} + +/// A ledger that has proved nothing: every map's frontier is still worth a walk. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoFrontiers; + +impl FrontierLedger for NoFrontiers { + fn frontier_exhausted(&self, _map: u8) -> bool { + false + } +} + +/// The session's own record of which maps have nothing left to walk to. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Frontiers(std::collections::BTreeSet); + +impl Frontiers { + /// Record that `map`'s frontier could not be reached. Idempotent. + pub fn record(&mut self, map: u8) { + self.0.insert(map); + } + + /// Forget `map`'s mark, because the run has just stood somewhere on it that it had not. + pub fn clear(&mut self, map: u8) { + self.0.remove(&map); + } + + /// How many maps are marked, for a log line and the tests. + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl FrontierLedger for Frontiers { + fn frontier_exhausted(&self, map: u8) -> bool { + self.0.contains(&map) + } +} + /// Tiles the cartridge has pushed the fly off, as the macros ask it. /// /// **The Viridian private-property tile** (2026-09-17, `infra/docs/macros-traps.md` row 37). diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs index 6eca02f..9260914 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs @@ -18,7 +18,7 @@ use crate::macros::{ use super::super::mapgrid::MapGrids; use super::super::state::PokeState; -use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile}; +use super::cartridge::{Areas, Frontiers, MacroState, Pushed, Stood, Talked, Targets, Tile}; use super::geography; use super::executor::{MacroAbort, MacroMachine, Refusal}; use super::palette::{MacroId, Palette}; @@ -64,6 +64,15 @@ pub struct PokemonPalette { /// centre's own map -- which is the moment the errand is discharged. A purchase or a heal marks /// nothing: what the errand asked for was the visit. Session state, not checkpointed. areas: Areas, + /// Maps whose frontier a `GO FRONTIER` has proved unreachable (`docs/design/macros.md` + /// section 12.14). + /// + /// Owned here beside the other session ledgers and written from the same two places they are: + /// the machine's refusal on one side and the frame the fly is standing on new ground on the + /// other. Like [`Pushed`] it has no window -- ground the map has fenced off is still fenced + /// off ten brain minutes later -- and unlike it, it is *cleared*, by the only event that can + /// change the answer. + frontiers: Frontiers, /// Tiles the cartridge pushes the fly off (`infra/docs/macros-traps.md` row 37). /// /// Owned here beside the other session ledgers and for the same reason. Unlike the target @@ -101,6 +110,7 @@ impl PokemonPalette { targets: Targets::new(), stood: Stood::default(), areas: Areas::default(), + frontiers: Frontiers::default(), pushed: Pushed::default(), grids: MapGrids::default(), now_ms: 0.0, @@ -137,6 +147,11 @@ impl PokemonPalette { self.pushed.len() } + /// How many maps have proved their frontier unreachable, for a log line and the tests. + pub fn exhausted(&self) -> usize { + self.frontiers.len() + } + /// Take whatever the machine's last finished macro earned into the session's ledgers. fn record_talk(&mut self) { if let Some((map, target)) = self.machine.take_talked() { @@ -154,6 +169,11 @@ impl PokemonPalette { if let Some((map, tile)) = self.machine.take_pushed() { self.pushed.record(map, tile); } + // A frontier the walk could not reach any of: a fact about this map's ground, with no + // window on it (section 12.14). + if let Some(map) = self.machine.take_exhausted() { + self.frontiers.record(map); + } if let Some((map, target, closer)) = self.machine.take_timeout() { self.targets.record_timeout(map, target, closer); } @@ -169,12 +189,23 @@ impl MacroPalette for PokemonPalette { fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { let (scene, bindings, standing) = { let Self { - machine, mode, palette: cached, talked, targets, stood, areas, pushed, grids, .. + machine, + mode, + palette: cached, + talked, + targets, + stood, + areas, + frontiers, + pushed, + grids, + .. } = self; let mut state = PokeState::with_ledgers( memory, ledger, talked, targets, &*stood, &*areas, &*pushed, ) - .caching_grid(grids); + .caching_grid(grids) + .with_frontiers(&*frontiers); // `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the // palette and the scene the feed reports cannot disagree about which frame they are // for. @@ -199,7 +230,12 @@ impl MacroPalette for PokemonPalette { // Section 12.7: the macro layer's own answer to "has the run stood here", because the // adapter's reward ledger cannot record a doormat. if let Some(player) = standing { - self.stood.record(player.map, Tile::new(player.x, player.y)); + // New ground under the fly is the one thing that can change which tiles of this map + // it can reach, so it is what clears the map's frontier mark (section 12.14). A tile + // the ledger already had changes nothing and clears nothing. + if self.stood.record(player.map, Tile::new(player.x, player.y)) { + self.frontiers.clear(player.map); + } // Section 13's `areaVisited(kind, area)`: the errand is paid on *entering*, so the // ledger is written from the same frame that records the ground. Standing on the // building's own map is the whole test -- the fly is inside it -- and it is written @@ -240,7 +276,8 @@ impl MacroPalette for PokemonPalette { &self.areas, &self.pushed, ) - .caching_grid(&mut self.grids); + .caching_grid(&mut self.grids) + .with_frontiers(&self.frontiers); self.machine.start(&palette, slot, &mut state) }; let started = match begun { @@ -281,7 +318,8 @@ impl MacroPalette for PokemonPalette { &self.areas, &self.pushed, ) - .caching_grid(&mut self.grids); + .caching_grid(&mut self.grids) + .with_frontiers(&self.frontiers); self.machine.step(&mut state) }; // A macro that just finished may have been the press that talked to something; the ledger @@ -317,8 +355,10 @@ impl MacroPalette for PokemonPalette { while self.machine.take_blocked().is_some() {} let _ = self.machine.take_timeout(); let _ = self.machine.take_reached(); - // A rollback is not the map pushing the fly anywhere. + // A rollback is not the map pushing the fly anywhere, nor its frontier going out of + // reach: the fly is about to be standing somewhere else. let _ = self.machine.take_pushed(); + let _ = self.machine.take_exhausted(); // The cached palette was dealt for a frame that is being thrown away. Dropping it makes // the next `start` before the next `observe` a nameless refusal, which presses nothing // and reports nothing, rather than a named refusal against a scene that no longer exists. @@ -401,6 +441,34 @@ mod tests { assert_eq!(palette.take_finished(), None); } + #[test] + fn standing_on_new_ground_is_what_clears_a_maps_frontier_mark() { + // Section 12.14's other half, wired: the mark is written by a `GO FRONTIER` that could + // reach none of its goals and cleared by the fly standing somewhere on that map it had + // not stood on before -- the only event that can change which tiles it can reach. A tile + // the stood ledger already has changes nothing, which is what keeps the mark from being + // cleared by the fly pacing the ground it has covered. + let mut wram = Wram::overworld(); + let mut palette = PokemonPalette::new(7); + palette.frontiers.record(REDS_HOUSE_1F); + assert_eq!(palette.exhausted(), 1); + + palette.observe(&mut wram, &NoLedger); + assert_eq!(palette.exhausted(), 0, "the first frame is new ground, so it clears"); + + palette.frontiers.record(REDS_HOUSE_1F); + palette.observe(&mut wram, &NoLedger); + assert_eq!( + palette.exhausted(), + 1, + "standing on the same tile again is not new ground and clears nothing" + ); + + wram.map(REDS_HOUSE_1F, 4, 4, 3, 5); + palette.observe(&mut wram, &NoLedger); + assert_eq!(palette.exhausted(), 0, "a tile the run had not stood on clears it"); + } + #[test] fn every_bound_slot_carries_a_name_and_a_gloss() { let mut wram = Wram::overworld(); diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs index 4407ace..f7a6827 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs @@ -575,6 +575,14 @@ pub struct MacroMachine { /// that finds no route to *any* of its goals has failed at all of them, and each is a key. blocked: Vec<(u8, TargetKey)>, reached: Option<(u8, TargetKey)>, + /// A map whose frontier a `GO FRONTIER` has just proved unreachable, waiting to be taken + /// into the session's ledger ([`super::cartridge::Frontiers`], section 12.14). + /// + /// The same shape as `pushed_tile` and for the same reason: it is a fact about the *ground* + /// rather than about a target, so the blocked ledger's ten-minute window is the wrong home + /// for it -- the window is what brought forty unreachable museum tiles back every ten + /// minutes, once per hold, for hours. + exhausted: Option, /// A tile the cartridge pushed the fly off, waiting to be taken into the session's ledger. /// /// Row 37 of `infra/docs/macros-traps.md`: a scripted push-back is a fact about the *ground*, @@ -651,6 +659,7 @@ impl MacroMachine { outcome: None, blocked: Vec::new(), reached: None, + exhausted: None, pushed_tile: None, timed_out: None, resume: VecDeque::new(), @@ -695,6 +704,13 @@ impl MacroMachine { // could not reach is what empties the list and takes the button off the pad. if let Some(map) = state.player().map(|player| player.map) { self.blocked.extend(unreachable.into_iter().map(|key| (map, key))); + // And for the frontier, the same fact one level up: every tile of this map the + // run has not stood on is unreachable from where the fly is standing. The + // blocked ledger is a window and this is not -- ground the map has fenced off is + // still fenced off ten brain minutes later (section 12.14). + if spec.kind == MacroKind::GoFrontier { + self.exhausted = Some(map); + } } return refuse(self, Refusal::NoRoute); }; @@ -857,6 +873,11 @@ impl MacroMachine { self.reached.take() } + /// The map a `no route` from `GO FRONTIER` earned, taken rather than read (section 12.14). + pub fn take_exhausted(&mut self) -> Option { + self.exhausted.take() + } + /// The tile a scripted push-back earned, taken rather than read (row 37). pub fn take_pushed(&mut self) -> Option<(u8, Tile)> { self.pushed_tile.take() @@ -883,8 +904,10 @@ impl MacroMachine { // that so a cancelled queue cannot leak into the next macro's finish. self.blocked.clear(); self.reached = None; - // A rollback is not the map pushing the fly anywhere. + // A rollback is not the map pushing the fly anywhere, and it is not the frontier being + // out of reach either: the fly is about to be somewhere else entirely. self.pushed_tile = None; + self.exhausted = None; self.timed_out = None; // A rollback puts the fly somewhere else on the map, so every suspended route is a route // from a tile it is no longer standing on. `take_resume` would refuse them one at a time; diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs index 7b96dfd..4f094e9 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs @@ -1388,6 +1388,16 @@ pub fn frontier_aims(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> { if counter_pending(state) { return Vec::new(); } + // **A map whose frontier this run has already proved it cannot reach** (section 12.14, the + // rung-10 museum). The unstood tiles are still there and still unstood -- the exhibit hall + // behind the admission desk, the far side of a fence -- and a walk that could not reach any + // of them refuses `no route`, writes them all to the blocked ledger and comes back ten brain + // minutes later when the window lapses, for ever. The mark has no window; it is cleared by + // the fly standing somewhere on this map it had not stood before, which is the only thing + // that can have changed the answer. + if state.frontier_exhausted() { + return Vec::new(); + } let mut local: Vec<(Tile, Facing)> = Vec::new(); for (tile, facing) in path::frontier(state) { // A tile the blocked ledger is resting, or one a script pushes the fly off (row 37). 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 cf804f5..192ba4a 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 @@ -115,6 +115,11 @@ struct World { areas: BTreeSet<(Amenity, u8)>, /// Tiles the cartridge pushes the fly off (`infra/docs/macros-traps.md` row 37). pushes: BTreeSet, + /// Maps a `GO FRONTIER` has proved it cannot reach the frontier of (section 12.14). + /// + /// Written by [`drive`] from the machine, exactly as `PokemonPalette` writes it in the sim + /// loop, so a test sees the ledger the next decision would see. + exhausted: BTreeSet, visited: BTreeSet, /// Tiles of this map the run has stood on, for `GO FRONTIER` and the plan's "untalked" test. stood: BTreeSet, @@ -232,6 +237,7 @@ impl World { counters: BTreeSet::new(), areas: BTreeSet::new(), pushes: BTreeSet::new(), + exhausted: BTreeSet::new(), stock: Vec::new(), visited: BTreeSet::new(), stood: BTreeSet::new(), @@ -673,6 +679,10 @@ impl MacroState for World { self.scene == Scene::Dialog || self.box_open } + fn frontier_exhausted(&mut self) -> bool { + self.exhausted.contains(&self.map) + } + /// A drawn box is what the reading rests on, so a prompt cannot be open with no box open: /// `pokemon_red::state::yes_no_prompt` gates on `wFontLoaded` before it looks at the tiles. fn yes_no_prompt(&mut self) -> bool { @@ -773,6 +783,9 @@ fn drive( while let Some((map, target)) = machine.take_blocked() { world.targets.record_blocked(map, target); } + if let Some(map) = machine.take_exhausted() { + world.exhausted.insert(map); + } return Err(refused); } // A walk's cap is its plan's, so the bound here is the ceiling on any macro plus slack. @@ -794,6 +807,9 @@ fn drive( while let Some((map, target)) = machine.take_blocked() { world.targets.record_blocked(map, target); } + if let Some(map) = machine.take_exhausted() { + world.exhausted.insert(map); + } if let Some((map, target, closer)) = machine.take_timeout() { world.targets.record_timeout(map, target, closer); } @@ -3193,6 +3209,69 @@ fn no_playable_scene_deals_an_empty_pad() { assert_eq!(plan::plan_for(Scene::Title, &mut title).bound(), 0); } +#[test] +fn a_frontier_no_walk_can_reach_takes_go_frontier_off_the_pad_and_keeps_it_off() { + // Section 12.14, the rung-10 museum. The sealed pocket below is map `0x34` in miniature: the + // unstood ground is real and it is fenced off, so `GO FRONTIER` refuses `no route` and writes + // every tile it could not reach to the blocked ledger -- which is a *window*. Before this the + // window lapsed after ten brain minutes and all of it was a candidate again: 1,235 starts in + // 47 minutes over two museum floors and a town. The mark has no window. + let mut world = World::room(); + for y in 0..8 { + for x in 0..8 { + world.stood.insert(Tile::new(x, y)); + } + } + world.stood.remove(&Tile::new(7, 7)); + world.walls.insert(Tile::new(6, 7)); + world.walls.insert(Tile::new(6, 6)); + world.walls.insert(Tile::new(7, 5)); + world.player = Tile::new(0, 0); + assert!(!super::palette::frontier_aims(&mut world).is_empty(), "the pocket is a frontier"); + assert!(precondition(MacroKind::GoFrontier, &mut world), "so the button is on the pad"); + + let refused = run(&mut world, MacroKind::GoFrontier).expect_err("the pocket is sealed"); + assert_eq!(refused.reason, Refusal::NoRoute); + assert!(world.exhausted.contains(&world.map), "the map is marked: {:?}", world.exhausted); + assert!(super::palette::frontier_aims(&mut world).is_empty(), "nothing left to aim at"); + assert!(!precondition(MacroKind::GoFrontier, &mut world), "and the button is off the pad"); + + // Ten brain minutes later the blocked window has lapsed and every tile of the pocket is a + // candidate again -- and the button is still off the pad, because the ground has not moved. + world.targets.clock(11.0 * 60_000.0); + assert!(!world.targets.blocked(world.map, TargetKey::Tile(Tile::new(7, 6)))); + assert!( + super::palette::frontier_aims(&mut world).is_empty(), + "the mark is not a window" + ); + // A map the fly walks to instead is untouched: the mark is one map's. + world.map = 0x35; + assert!(!super::palette::frontier_aims(&mut world).is_empty(), "another map is its own"); +} + +#[test] +fn a_frontier_mark_is_the_stood_ledgers_to_clear() { + // The two halves of the rule as the types have them: the stood ledger answers "this is + // ground the run had not stood on", which is the only event that can change which tiles the + // fly can reach, and that answer is what clears the map's mark. + let mut stood = super::cartridge::Stood::default(); + assert!(stood.record(2, Tile::new(4, 4)), "the first time is new ground"); + assert!(!stood.record(2, Tile::new(4, 4)), "the second time is not"); + assert!(stood.record(0x34, Tile::new(4, 4)), "and a tile is a tile of one map"); + + use super::cartridge::FrontierLedger; + let mut frontiers = super::cartridge::Frontiers::default(); + assert!(!frontiers.frontier_exhausted(0x34)); + frontiers.record(0x34); + frontiers.record(0x34); + assert!(frontiers.frontier_exhausted(0x34), "idempotent"); + assert!(!frontiers.frontier_exhausted(0x35), "and one map's"); + assert_eq!(frontiers.len(), 1); + frontiers.clear(0x34); + assert!(!frontiers.frontier_exhausted(0x34)); + assert!(frontiers.is_empty()); +} + #[test] fn a_walk_that_could_only_get_closer_now_excludes_what_it_cannot_reach() { // Two readings of the same pocket, in the order they were measured diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 1faf84d..5eb6109 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -25,8 +25,9 @@ use crate::adapter::{MapEdge, MapExit, MapTile, MemoryReader}; use crate::macros::{RunLedger, NoLedger}; use super::macros::cartridge::{ - AreaLedger, Edge, ExitId, MacroState, NoAreas, NoPushed, NoStood, NoTalk, NoTargets, - Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, TargetLedger, Tile, + AreaLedger, Edge, ExitId, FrontierLedger, MacroState, NoAreas, NoFrontiers, NoPushed, NoStood, + NoTalk, NoTargets, Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, + TargetLedger, Tile, }; use super::macros::geography::Amenity; use super::mapgrid::{self, MapGrids}; @@ -1196,6 +1197,10 @@ pub struct PokeState<'a> { /// ([`PokeState::caching_grid`]) so that a precondition asking for the frontier costs a /// refcount instead of a map. grids: Option<&'a mut MapGrids>, + /// Which maps have proved their frontier unreachable (`docs/design/macros.md` section + /// 12.14). A builder rather than a constructor parameter, exactly as the grid cache is: the + /// sim loop passes one and everything else narrows to "nothing proved". + frontiers: &'a dyn FrontierLedger, } impl<'a> PokeState<'a> { @@ -1214,6 +1219,7 @@ impl<'a> PokeState<'a> { areas: &NoAreas, pushed: &NoPushed, grids: None, + frontiers: &NoFrontiers, } } @@ -1228,6 +1234,7 @@ impl<'a> PokeState<'a> { areas: &NoAreas, pushed: &NoPushed, grids: None, + frontiers: &NoFrontiers, } } @@ -1245,7 +1252,7 @@ impl<'a> PokeState<'a> { areas: &'a dyn AreaLedger, pushed: &'a dyn PushedLedger, ) -> Self { - Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None } + Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None, frontiers: &NoFrontiers } } /// Keep the decoded map grid in `grids` instead of decoding it per question. @@ -1258,6 +1265,16 @@ impl<'a> PokeState<'a> { self.grids = Some(grids); self } + + /// Answer [`MacroState::frontier_exhausted`] from `frontiers` instead of "nothing proved". + /// + /// A builder for the same reason the grid cache is one: it is the sim loop's own session + /// state ([`super::macros::driver::PokemonPalette`]) and every other caller -- the tests, the + /// probes, the ROM harnesses -- wants the narrowing. + pub fn with_frontiers(mut self, frontiers: &'a dyn FrontierLedger) -> Self { + self.frontiers = frontiers; + self + } } impl GameState for PokeState<'_> { @@ -1346,6 +1363,10 @@ impl MacroState for PokeState<'_> { text_box(self.memory).open } + fn frontier_exhausted(&mut self) -> bool { + player(self.memory).is_some_and(|player| self.frontiers.frontier_exhausted(player.map)) + } + fn yes_no_prompt(&mut self) -> bool { yes_no_prompt(self.memory) }