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 614ac97..27ae9cc 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 @@ -552,6 +552,22 @@ pub trait MacroState: GameState { false } + /// Whether the macro in `slot` was refused, for want of a route or of its precondition, on + /// the tile the fly is standing on now, inside the blocked window (`infra/docs/macros-traps.md` + /// row 57). + /// + /// The dealer asks the cheap question and `start` asks the real one, so a button can be dealt + /// that its own route search refuses. The blocked ledger usually closes that gap -- the refusal + /// writes what it could not reach and the dealer stops offering it -- but not for a list that + /// deliberately ignores that ledger: a last resort. Live on rung 10, Pewter City, two hours: + /// the pad was `GO ROUTE` and nothing else, refused `no route` every 800 brain ms, because + /// `ways`'s last resort ignores the window its own refusal writes. A refusal from *here* is a + /// fact about here; the fly standing anywhere else, or the window closing, is what can change + /// it. Session state, never checkpointed. + fn refused_here(&mut self, _slot: u8) -> bool { + false + } + /// Whether `GO ITEM` or `GO NPC` has already arrived at `target` and faced it this session. /// /// The other half of the same stall: `GO NPC` walked to the same villager again and again, @@ -907,6 +923,13 @@ pub trait TargetLedger { /// Whether `target` on `map` has been arrived at and faced this session. fn reached(&self, map: u8, target: TargetKey) -> bool; + + /// Whether the macro in `slot` was refused standing on `tile` of `map`, inside the window + /// ([`MacroState::refused_here`]). Defaulted, so a ledger that never records one refuses + /// nothing. + fn refused(&self, _map: u8, _slot: u8, _tile: Tile) -> bool { + false + } } /// Ledgers that have recorded nothing: every target still a candidate. @@ -948,6 +971,9 @@ pub struct Targets { /// stayed shut. The window keeps the loop bounded at one walk per target per window while /// leaving the conversation available. reached: std::collections::BTreeMap<(u8, TargetKey), f64>, + /// (map, slot, tile) -> the brain millisecond a macro was refused standing there + /// ([`MacroState::refused_here`], row 57). The same window as `blocked`. + refused: std::collections::BTreeMap<(u8, u8, Tile), f64>, /// The brain clock of the frame being decided, from [`super::driver::PokemonPalette`]. now_ms: f64, /// How long an entry excludes its target, in brain milliseconds. @@ -989,6 +1015,7 @@ impl Targets { blocked: std::collections::BTreeMap::new(), strikes: std::collections::BTreeMap::new(), reached: std::collections::BTreeMap::new(), + refused: std::collections::BTreeMap::new(), now_ms: 0.0, window_ms: minutes * MINUTE_MS, } @@ -1006,6 +1033,15 @@ impl Targets { self.window_ms / MINUTE_MS } + /// Record that the macro in `slot` was refused -- no route, or no precondition -- with the fly + /// standing on `tile` of `map` (row 57). Re-recording restarts the window, as `blocked` does. + pub fn record_refused(&mut self, map: u8, slot: u8, tile: Tile) { + let now = self.now_ms; + let window = self.window_ms; + self.refused.retain(|_, at| now - *at < window); + self.refused.insert((map, slot, tile), now); + } + /// Record a `Blocked` or `Timeout` abort against the target it was aimed at. /// /// Re-recording restarts the window, which is the honest reading: the walk failed *again*. @@ -1080,4 +1116,8 @@ impl TargetLedger for Targets { fn reached(&self, map: u8, target: TargetKey) -> bool { self.reached.get(&(map, target)).is_some_and(|at| self.now_ms - *at < self.window_ms) } + + fn refused(&self, map: u8, slot: u8, tile: Tile) -> bool { + self.refused.get(&(map, slot, tile)).is_some_and(|at| self.now_ms - *at < self.window_ms) + } } 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 9cf77ac..4132f63 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 @@ -220,6 +220,11 @@ impl PokemonPalette { if let Some((map, tile)) = self.machine.take_pushed() { self.pushed.record(map, tile); } + // A refusal from where the fly is standing: that button is not dealt again from this tile + // for the window (row 57). The fly moving, or the window closing, deals it again. + if let Some((map, slot, tile)) = self.machine.take_refused() { + self.targets.record_refused(map, slot, 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() { @@ -452,6 +457,7 @@ impl MacroPalette for PokemonPalette { // reach: the fly is about to be standing somewhere else. let _ = self.machine.take_pushed(); let _ = self.machine.take_exhausted(); + let _ = self.machine.take_refused(); // 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. 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 90d7309..e100568 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 @@ -590,6 +590,10 @@ pub struct MacroMachine { /// from the frame the push is seen, because by the time the next macro starts the fly has been /// walked somewhere else. pushed_tile: Option<(u8, Tile)>, + /// A refusal the route search or the precondition made, and where the fly stood for it -- + /// `(map, slot, tile)`, waiting to be taken into the session's ledger + /// ([`super::cartridge::Targets::record_refused`], row 57 of `infra/docs/macros-traps.md`). + refused_at: Option<(u8, u8, Tile)>, /// The target a frame-cap `Timeout` spent itself on, and whether the walk ended nearer a goal /// than it began: [`super::cartridge::Targets::record_timeout`]'s two arguments. /// @@ -661,6 +665,7 @@ impl MacroMachine { reached: None, exhausted: None, pushed_tile: None, + refused_at: None, timed_out: None, resume: VecDeque::new(), pending_talk: None, @@ -689,10 +694,19 @@ impl MacroMachine { machine.outcome = Some((spec.name, MacroAbort::Refused)); Err(MacroRefused { slot, reason }) }; + // Where the fly stands for a refusal that is a fact about *here* (row 57): no route from + // this tile, or a precondition the dealer and the starter answered differently on it. + let here = state.player().map(|player| (player.map, Tile::new(player.x, player.y))); + let refused_here = |machine: &mut Self| { + if let Some((map, tile)) = here { + machine.refused_at = Some((map, slot.0, tile)); + } + }; if class(scene) != class(palette.scene) { return refuse(self, Refusal::WrongScene); } if !precondition(spec.kind, state) { + refused_here(self); return refuse(self, Refusal::Precondition); } let mut unreachable: Vec = Vec::new(); @@ -712,6 +726,7 @@ impl MacroMachine { self.exhausted = Some(map); } } + refused_here(self); return refuse(self, Refusal::NoRoute); }; // The map the target was chosen on, so an entry cannot be read back on another map. @@ -883,6 +898,12 @@ impl MacroMachine { self.pushed_tile.take() } + /// Where the last `no route` or `precondition` refusal happened, taken rather than read + /// (row 57). + pub fn take_refused(&mut self) -> Option<(u8, u8, Tile)> { + self.refused_at.take() + } + /// The frame-cap timeout a walk earned, with whether it ended nearer its goal, taken rather /// than read. The ledger decides what it means. pub fn take_timeout(&mut self) -> Option<(u8, TargetKey, bool)> { @@ -908,6 +929,7 @@ impl MacroMachine { // out of reach either: the fly is about to be somewhere else entirely. self.pushed_tile = None; self.exhausted = None; + self.refused_at = 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 227504f..c7a3fae 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 @@ -440,7 +440,10 @@ impl Palette { pub fn for_scene(scene: Scene, state: &mut dyn MacroState) -> Self { let mut slots: [Option; SLOTS] = [None; SLOTS]; for kind in scene_set(scene, state) { - if precondition(kind, state) { + // A button refused from this very tile inside the window is not dealt again from it + // (row 57): the dealer's question is the cheap one, and `start`'s answer to the real + // one outranks it until the fly stands somewhere else or the window closes. + if precondition(kind, state) && !state.refused_here(kind.slot()) { slots[usize::from(kind.slot())] = Some(MacroSpec::of(kind)); } } 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 6688280..a006d78 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 @@ -729,6 +729,10 @@ impl MacroState for World { self.targets.reached(self.map, target) } + fn refused_here(&mut self, slot: u8) -> bool { + self.targets.refused(self.map, slot, self.player) + } + fn objective(&mut self) -> Option { self.objective } @@ -786,6 +790,9 @@ fn drive( if let Some(map) = machine.take_exhausted() { world.exhausted.insert(map); } + if let Some((map, slot, tile)) = machine.take_refused() { + world.targets.record_refused(map, slot, tile); + } return Err(refused); } // A walk's cap is its plan's, so the bound here is the ceiling on any macro plus slack. @@ -4874,9 +4881,80 @@ fn a_tile_the_cartridge_pushes_the_fly_off_is_not_a_tile_to_walk_to() { } // --------------------------------------------------------------------------------------------- -// Row 57: an escorted walk, and where it was escorted from +// Row 57: a last resort refused from here is not dealt again from here // --------------------------------------------------------------------------------------------- +/// Pewter City as the live pad found it: the fly fenced into a corner of the town, the +/// frontier marked, every person and sign accounted for, the errands paid -- and the only way +/// out anything aims at, the gym's door, on the far side of the fence. +fn fenced_in_pewter() -> World { + let mut world = World::room().at(3, 3); + world.map = maps::PEWTER_CITY; + world.size = MapSize { width: 20, height: 12 }; + // The fence: a wall the height of the map, with the fly on the near side of it. + for y in 0..12 { + world.walls.insert(Tile::new(10, y)); + } + world.warps = vec![Warp { x: 15, y: 3, destination_warp: 0, destination_map: maps::PEWTER_GYM }]; + world.seen_maps.insert(maps::PEWTER_GYM); + world.objective = Some(Objective { + map: maps::PEWTER_GYM, + tile: None, + warp: None, + edge: None, + target: Some(PlaceKind::Person), + }); + world.areas.insert((Amenity::Mart, maps::PEWTER_CITY)); + world.areas.insert((Amenity::Center, maps::PEWTER_CITY)); + world.exhausted.insert(maps::PEWTER_CITY); + world +} + +#[test] +fn a_way_out_refused_from_here_is_not_dealt_again_from_here() { + // Row 57, live on rung 10 for two hours: the pad was `GO ROUTE` alone, `refused` every 800 + // brain ms, nothing pressed. `ways`'s last resort deliberately ignores the blocked ledger, so + // the `no route` refusal -- which writes what it could not reach to that ledger -- could not + // take the button off the pad, and the same refusal re-stamped the gym door every hold, which + // kept `GO OBJECTIVE`'s only goal excluded for ever. + let mut world = fenced_in_pewter(); + let door = TargetKey::Exit(ExitId::Warp(0)); + let names = |world: &mut World| -> Vec<&'static str> { + let scene = world.scene(); + plan::plan_for(scene, world).slots.iter().flatten().map(|spec| spec.name).collect() + }; + assert_eq!(names(&mut world), ["GO OBJECTIVE", "GO ROUTE"], "the door, two ways"); + + assert_eq!( + run(&mut world, MacroKind::GoObjective).map_err(|refused| refused.reason), + Err(Refusal::NoRoute) + ); + assert!(world.targets.blocked(world.map, door), "the door is excluded for the window"); + assert_eq!(names(&mut world), ["GO ROUTE"], "and the last resort still deals it"); + assert_eq!( + run(&mut world, MacroKind::GoRoute).map_err(|refused| refused.reason), + Err(Refusal::NoRoute), + "the route search agrees with the fence" + ); + + // The trap: before row 57 the same button was dealt again, from the same tile, on the next + // hold, for ever. + assert!( + names(&mut world).is_empty(), + "a button refused from this tile is not dealt again from it: {:?}", + names(&mut world) + ); + + // It is a fact about *here*, not a retirement: standing anywhere else deals it again ... + world.player = Tile::new(3, 4); + assert_eq!(names(&mut world), ["GO ROUTE"]); + // ... and so does the window closing on the same tile. + world.player = Tile::new(3, 3); + assert!(names(&mut world).is_empty()); + world.targets.clock(BLOCKED_MINUTES_DEFAULT * 60_000.0 + 1.0); + assert!(names(&mut world).contains(&"GO ROUTE"), "{:?}", names(&mut world)); +} + #[test] fn an_escorted_walk_walls_the_tile_it_reached_not_the_one_it_set_out_from() { // Row 57's other half. Pewter City's youngster takes the joypad on four tiles by the road 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 ef62ff3..836780c 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -1804,6 +1804,12 @@ impl MacroState for PokeState<'_> { self.targets.blocked(map, target) } + /// Whether the macro in `slot` was refused on the tile the fly is standing on (row 57). + fn refused_here(&mut self, slot: u8) -> bool { + let Some(player) = player(self.memory) else { return false }; + self.targets.refused(player.map, slot, Tile::new(player.x, player.y)) + } + /// Whether `GO ITEM` or `GO NPC` has already reached `target` on the map that is loaded. fn reached(&mut self, target: TargetKey) -> bool { let Some(map) = player(self.memory).map(|player| player.map) else { return false };