diff --git a/services/flysim/crates/flybrain-gb/src/macros.rs b/services/flysim/crates/flybrain-gb/src/macros.rs index 3183ab1..55bd04a 100644 --- a/services/flysim/crates/flybrain-gb/src/macros.rs +++ b/services/flysim/crates/flybrain-gb/src/macros.rs @@ -264,6 +264,27 @@ pub trait MacroPalette: Send { /// Give up on whatever is running, because the sim loop is rolling the game back. The /// abandoned macro is reported by the next [`MacroPalette::take_finished`]. fn cancel(&mut self); + + /// Whether the last [`MacroPalette::observe`] saw the fly **nearer its objective than it has + /// been** since that objective was set, measured in map hops. + /// + /// The ratchet's stall window is reset by exploration -- one new tile + /// (`docs/design/ladder.md`, the 2026-09-17 progress rule) -- and a fly crossing a town it + /// has already covered to reach the rung's own door earns no new ground while it does it. + /// That is the rung-10 stall of 2026-09-22 in one line: two "Stuck" rollbacks inside half an + /// hour, both of them on a fly that was walking, both of them landing it back where it had + /// started. Getting nearer the objective than this run has ever been is the other thing that + /// is plainly progress, and it is a *level* rather than a counter so nothing is checkpointed + /// and nothing can drift: it is true on the frame the distance falls and false after. + /// + /// Read by the sim loop and by nothing else. No macro is ranked by it, no button is bound on + /// it and it presses nothing (`docs/design/macros.md` section 12): it is the loop's own + /// answer to "is this run getting somewhere". + /// + /// The default is `false`, which is a palette with no objective to be nearer to. + fn nearer_the_objective(&self) -> bool { + false + } } /// Every macro channel a game's palette can ever bind, in the contract's own order, or empty for 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 9260914..bde4466 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 @@ -21,7 +21,7 @@ use super::super::state::PokeState; 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}; +use super::palette::{self, MacroId, Palette}; use super::plan; use super::state::{GameState, Scene}; @@ -88,6 +88,17 @@ pub struct PokemonPalette { /// frame. It is a *cache* rather than a ledger: nothing about the run is in it, only what the /// cartridge's own tables say about the ground. grids: MapGrids, + /// The fewest map hops between the fly and its objective this run has managed, and which + /// objective that was (`docs/design/macros.md` section 12.15). + /// + /// Session state beside the ledgers and never checkpointed, and unlike them it is not a fact + /// about the map at all: it is the one reading the *sim loop* takes from the macro layer, for + /// the ratchet's stall window. The objective is carried with the number because the ladder's + /// next rung changes as the run climbs, and "nearer" means nothing across two different + /// places. + nearest: Option<(u8, u32)>, + /// Whether the last `observe` was the frame that number fell on. + nearer: bool, /// The brain clock of the frame being decided, from [`MacroPalette::clock`]. /// /// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather @@ -113,6 +124,8 @@ impl PokemonPalette { frontiers: Frontiers::default(), pushed: Pushed::default(), grids: MapGrids::default(), + nearest: None, + nearer: false, now_ms: 0.0, } } @@ -187,7 +200,7 @@ impl MacroPalette for PokemonPalette { } fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { - let (scene, bindings, standing) = { + let (scene, bindings, standing, approach) = { let Self { machine, mode, @@ -224,8 +237,17 @@ impl MacroPalette for PokemonPalette { // -- the coordinates and the loaded map header are from different frames, and a tile // recorded from that pair is a tile of nowhere. let standing = (!state.scripted()).then(|| state.player()).flatten(); + // How far the objective is, over the same map graph `GO OBJECTIVE` walks (section + // 12.15). Read from the same frame and the same state everything else is, and only + // where the fly is its own master, for the same reason the ground is. + let approach = standing.and_then(|player| { + let objective = palette::objective_place(&mut state)?; + let hops = + geography::hops(geography::region_at(player.map, player.y), objective.map)?; + Some((objective.map, hops)) + }); *cached = Some(palette); - (scene, bindings, standing) + (scene, bindings, standing, approach) }; // 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. @@ -248,6 +270,22 @@ impl MacroPalette for PokemonPalette { self.areas.record(kind, area); } } + // Section 12.15: nearer the objective than this run has ever been, which is the other + // thing that is plainly progress and which the ratchet's stall window cannot see in the + // exploration ledger. A level, true on the frame the number falls and false after, so + // there is nothing to checkpoint and nothing to drift. A different objective starts the + // measurement again: the ladder's next rung moves as the run climbs and "nearer" means + // nothing across two different places. + self.nearer = match (self.nearest, approach) { + (_, None) => false, + (None, Some(_)) => false, + (Some((was, best)), Some((map, hops))) => map == was && hops < best, + }; + self.nearest = match (self.nearest, approach) { + (_, None) => self.nearest, + (Some((was, best)), Some((map, hops))) if map == was => Some((map, best.min(hops))), + (_, Some(now)) => Some(now), + }; // The talked entry `observe_frame` may just have earned, into the ledger the next frame // reads. self.record_talk(); @@ -343,6 +381,10 @@ impl MacroPalette for PokemonPalette { Some((name, outcome(abort))) } + fn nearer_the_objective(&self) -> bool { + self.nearer + } + fn cancel(&mut self) { self.machine.cancel(); // A cancelled macro talked to nothing, and a stale entry would silence a person for the @@ -413,6 +455,8 @@ mod tests { use crate::adapter::{MapEdge, MapExit}; use crate::macros::NoLedger; use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, Wram}; + use crate::pokemon_red::macros::geography::Amenity; + use crate::pokemon_red::maps; use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState}; /// A ledger with one exit in it, for the wiring test below. @@ -441,6 +485,67 @@ mod tests { assert_eq!(palette.take_finished(), None); } + /// A ledger whose objective is one map, for the approach reading below. + struct Bound(u8); + + impl RunLedger for Bound { + fn exit_visited(&self, _exit: MapExit) -> bool { + false + } + + fn objective(&self) -> Option { + Some(crate::adapter::MapPlace { + map: self.0, + tile: None, + warp: None, + edge: None, + target: None, + }) + } + } + + #[test] + fn the_objective_getting_nearer_is_read_once_per_step_of_the_road() { + // Section 12.15, the rung-10 stall. The ratchet's stall window is reset by ground never + // stood on, and a fly walking a road it has already covered earns none -- so this is the + // other reading, and what it has to be is a *level* that is true on the frame the hop + // count falls and false on every frame after it. Pewter's own museum is the road: the + // upper floor is three hops from the gym, the ground floor two, the town one. + let mut wram = Wram::new(); + wram.started().map(maps::PEWTER_MUSEUM_2F, 4, 4, 3, 6).facing(0).house_collision(); + let mut palette = PokemonPalette::new(7); + // Both of Pewter's errands discharged, so the objective is the rung's own place for the + // whole test: section 13 puts an unvisited mart or centre *ahead* of it, and that is a + // different place to be near. + palette.areas.record(Amenity::Mart, maps::PEWTER_CITY); + palette.areas.record(Amenity::Center, maps::PEWTER_CITY); + let ledger = Bound(maps::PEWTER_GYM); + + palette.observe(&mut wram, &ledger); + assert!(!palette.nearer_the_objective(), "the first reading is a measurement, not a step"); + palette.observe(&mut wram, &ledger); + assert!(!palette.nearer_the_objective(), "standing still is not nearer"); + + wram.map(maps::PEWTER_MUSEUM_1F, 4, 4, 3, 6); + palette.observe(&mut wram, &ledger); + assert!(palette.nearer_the_objective(), "down the stairs is one hop nearer"); + palette.observe(&mut wram, &ledger); + assert!(!palette.nearer_the_objective(), "and it is read once, not held"); + + // Back upstairs is not progress, and it does not undo the number either: the measurement + // is the best this run has managed, so walking the road twice pays once. + wram.map(maps::PEWTER_MUSEUM_2F, 4, 4, 3, 6); + palette.observe(&mut wram, &ledger); + assert!(!palette.nearer_the_objective()); + wram.map(maps::PEWTER_MUSEUM_1F, 4, 4, 3, 6); + palette.observe(&mut wram, &ledger); + assert!(!palette.nearer_the_objective(), "ground already gained is not gained again"); + + wram.map(maps::PEWTER_CITY, 4, 4, 3, 6); + palette.observe(&mut wram, &ledger); + assert!(palette.nearer_the_objective(), "out of the front door is nearer still"); + } + #[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 diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/geography.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/geography.rs index 8abc285..117ed39 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/geography.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/geography.rs @@ -341,6 +341,39 @@ pub fn next_hop(from: Region, to: u8) -> Option { None } +/// How many hops the shortest known route from `from` to the map `to` takes, or `None` when none +/// is known. +/// +/// [`next_hop`]'s own breadth-first walk, counting instead of naming: `Some(0)` when the fly is +/// already on `to`, `Some(1)` for a door out of this map into it, and `None` for a map the table +/// cannot route to -- which is the same "nothing is guessed" [`next_hop`] answers with. +/// +/// What it is *for* is the ratchet (`docs/design/ladder.md`, the 2026-09-17 progress rule): the +/// stall window is reset by exploration, and a fly crossing a town it has already covered to +/// reach the rung's own door earns no new ground while it does it. "Nearer the objective than +/// this run has ever been" is the other thing that is plainly progress, and it is this number +/// falling. Nothing about the *choice* reads it: no macro is ranked by it and no button is bound +/// on it. +pub fn hops(from: Region, to: u8) -> Option { + if from.map == to { + return Some(0); + } + let mut seen: HashSet = HashSet::from([from]); + let mut queue: VecDeque<(Region, u32)> = VecDeque::new(); + queue.push_back((from, 0)); + while let Some((region, depth)) = queue.pop_front() { + if region.map == to { + return Some(depth); + } + for next in region_neighbours(region) { + if seen.insert(next) { + queue.push_back((next, depth + 1)); + } + } + } + None +} + /// A building an area has at most one of, and which this run may not have been into yet. /// /// `docs/design/macros.md` section 13: the two errands. A kind rather than two parallel tables @@ -593,6 +626,34 @@ mod tests { assert_eq!(amenity_at(maps::PEWTER_MUSEUM_2F), None); } + #[test] + fn the_hop_count_is_the_road_measured_rather_than_named() { + let at = |map: u8| Region::whole(map); + assert_eq!(hops(at(maps::PEWTER_CITY), maps::PEWTER_CITY), Some(0), "already there"); + assert_eq!(hops(at(maps::PEWTER_CITY), maps::PEWTER_GYM), Some(1), "one door"); + assert_eq!(hops(at(maps::PEWTER_MUSEUM_1F), maps::PEWTER_GYM), Some(2)); + assert_eq!(hops(at(maps::PEWTER_MUSEUM_2F), maps::PEWTER_GYM), Some(3)); + // The count agrees with the hop by hop answer, which is the thing it has to: walking the + // road one `next_hop` at a time takes exactly this many steps. + let mut here = at(maps::PEWTER_MUSEUM_2F); + let mut steps = 0; + while let Some(hop) = next_hop(here, maps::PEWTER_GYM) { + here = Region::whole(hop); + steps += 1; + assert!(steps < 10, "the road to the gym does not wander"); + } + assert_eq!(here.map, maps::PEWTER_GYM); + assert_eq!(steps, 3); + // A split map is measured from the piece the fly is standing in, exactly as `next_hop` is. + assert_eq!(hops(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(1)); + // Five from the south half, because the belt of trees between the halves needs CUT and + // the road is the forest: the south gate, the forest, the north gate, Route 2's north + // half, Pewter. + assert_eq!(hops(region_at(maps::ROUTE_2, 43), maps::PEWTER_CITY), Some(5)); + // And nothing is guessed. + assert_eq!(hops(at(maps::PALLET_TOWN), 0xf0), None); + } + #[test] fn a_maps_area_is_its_town_indoors_and_out() { // Outdoors a map is its own area, including a route -- which has no amenity row, so it diff --git a/services/flysim/crates/flybrain-gb/src/ratchet.rs b/services/flysim/crates/flybrain-gb/src/ratchet.rs index cca7aba..895fcbf 100644 --- a/services/flysim/crates/flybrain-gb/src/ratchet.rs +++ b/services/flysim/crates/flybrain-gb/src/ratchet.rs @@ -186,6 +186,40 @@ impl Ratchet { game_over: bool, capture: F, ) -> bool + where + F: FnOnce() -> Snapshot, + { + self.observe_with_progress(safe, rank, coverage, now, game_over, false, capture) + } + + /// [`Ratchet::observe_with_game_over`] plus a progress signal the coverage figure cannot + /// carry. + /// + /// `progressed` is the caller saying "something happened on this sample that is plainly + /// progress, and it is not a number that only grows". The stall window is restarted by it + /// exactly as a rise in `coverage` restarts it, and nothing else changes: no budget is + /// spent, no snapshot is taken and no trigger is skipped. + /// + /// It exists because coverage is the *only* progress the window could see, and coverage is + /// ground never stood on. A fly crossing a town it has already covered to reach the rung's + /// own door is getting somewhere and earns none. Measured on rung 10, 2026-09-22: two + /// "Stuck" rollbacks inside half an hour, both on a fly that was walking, each one landing + /// it back where it had started. What the Pokemon loop passes here is "nearer the objective, + /// in map hops, than this run has ever been" (`docs/design/macros.md` section 12.15), which + /// can only fire as many times as the road is long. + /// + /// Nothing in this module knows what the signal means, which is the same bargain `coverage` + /// is: the ladder, the map graph and the objective all belong to the adapter. + pub fn observe_with_progress( + &mut self, + safe: bool, + rank: u64, + coverage: u64, + now: u64, + game_over: bool, + progressed: bool, + capture: F, + ) -> bool where F: FnOnce() -> Snapshot, { @@ -200,7 +234,7 @@ impl Ratchet { } else { self.unsafe_since = None; } - if coverage > self.state.coverage { + if coverage > self.state.coverage || progressed { self.state.last_progress = now; } self.state.coverage = self.state.coverage.max(coverage); @@ -458,4 +492,51 @@ mod tests { assert!(!ratchet.observe(true, 1, 2, 230_000, || snapshot(1))); assert!(ratchet.observe(true, 1, 2, 239_001, || snapshot(1))); } + + #[test] + fn a_map_the_run_has_never_stood_on_is_coverage_and_resets_the_window() { + // The half of the 2026-09-17 rule that already held, pinned because the other half is + // new: entering a map for the first time is ground never stood on, so it arrives here as + // a rise in `coverage` and needs nothing of its own. Re-entering a map the run has + // covered is not, which is exactly the rung-10 museum and why the signal below exists. + let mut ratchet = Ratchet::new(); + ratchet.observe(true, 1, 100, 0, || snapshot(1)); + // A new map: eighty tiles nobody had stood on. + assert!(!ratchet.observe(true, 1, 180, 100_000, || snapshot(1))); + // Walking it again for two minutes adds none, and the window ages from the arrival. + assert!(!ratchet.observe(true, 1, 180, 219_000, || snapshot(1))); + assert!(ratchet.observe(true, 1, 180, 220_001, || snapshot(1))); + } + + #[test] + fn getting_nearer_the_objective_resets_the_stall_window_without_spending_anything() { + // The rung-10 rollbacks of 2026-09-22: a fly walking a town it has already covered + // toward the rung's own door earns no coverage while it does it, and the window aged out + // twice in half an hour. A caller's progress signal restarts the window exactly as + // coverage does -- and does nothing else. + let mut ratchet = Ratchet::new(); + ratchet.observe(true, 1, 100, 0, || snapshot(1)); + // 119 s of walking ground the run has covered, and then one step nearer the gym. + assert!(!ratchet.observe_with_progress(true, 1, 100, 119_000, false, true, || snapshot(1))); + let state = ratchet.state; + assert_eq!(state.last_progress, 119_000); + assert_eq!((state.recoveries, state.attempts), (0, 0), "progress spends no budget"); + // The window now runs from there rather than from the start. + assert!(!ratchet.observe(true, 1, 100, 238_000, || snapshot(1))); + assert!(ratchet.observe(true, 1, 100, 239_001, || snapshot(1))); + } + + #[test] + fn a_progress_signal_cannot_conjure_a_recovery_or_skip_a_trigger() { + // It restarts the window; it is not a trigger and it is not a snapshot. With nothing + // archived there is nothing to recover to, and an unsafe sample still never recovers. + let mut ratchet = Ratchet::new(); + assert!(!ratchet.observe_with_progress(true, 0, 0, 0, false, true, || snapshot(1))); + assert!(ratchet.snapshot.is_none(), "progress captures nothing"); + let mut armed = Ratchet::new(); + armed.observe(true, 1, 1, 0, || snapshot(1)); + // Unsafe, stalled, and told that something progressed: still no recovery, because an + // unsafe sample would restore on top of whatever made it unsafe. + assert!(!armed.observe_with_progress(false, 1, 1, 400_000, false, true, || snapshot(1))); + } } diff --git a/services/flysim/crates/flysim/src/macros.rs b/services/flysim/crates/flysim/src/macros.rs index a93c45c..95f5be5 100644 --- a/services/flysim/crates/flysim/src/macros.rs +++ b/services/flysim/crates/flysim/src/macros.rs @@ -500,6 +500,15 @@ impl MacroLayer { /// The sim loop passes this to [`flybrain_core::decoder::PopulationDecoder::decode_bound`] on /// the same frame, which is why it is the layer's to answer: the bindings are what the last /// `observe` dealt, and nothing else in the loop knows them. + /// Whether this frame saw the fly nearer its objective than the run has managed before. + /// + /// Straight through from the palette ([`MacroPalette::nearer_the_objective`]), for the + /// ratchet's stall window and for nothing else: the macro layer neither reads it back nor + /// presses anything because of it. + pub fn nearer_the_objective(&self) -> bool { + self.palette.nearer_the_objective() + } + pub fn bound_channels(&self) -> Vec { self.bindings .iter() diff --git a/services/flysim/crates/flysim/src/simloop.rs b/services/flysim/crates/flysim/src/simloop.rs index 8a1e852..ecf3440 100644 --- a/services/flysim/crates/flysim/src/simloop.rs +++ b/services/flysim/crates/flysim/src/simloop.rs @@ -1054,12 +1054,20 @@ impl Sim { } else { None }; - let recover = self.ratchet.observe_with_game_over( + // The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17 + // rule as amended 2026-09-22): coverage is ground never stood on, and a fly crossing a + // town it has already covered to reach the rung's own door earns none of it while it is + // plainly getting somewhere. The macro layer answers with the map graph it already walks + // (`docs/design/macros.md` section 12.15); in raw mode there is no layer and no + // objective, and the answer is false. + let nearer = self.macros.as_ref().is_some_and(MacroLayer::nearer_the_objective); + let recover = self.ratchet.observe_with_progress( safe, u64::from(progress.rank), progress.unique_locations as u64, ms as u64, self.adapter.game_over(), + nearer, || captured.expect("the ratchet only captures when a snapshot was prepared"), ); if recover {