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) }