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 f6091ea..eddd01f 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 @@ -273,7 +273,7 @@ impl PokemonPalette { } // A tile the cartridge drove the fly off: no window, because the map is like that until // the event that unlocks it, and nothing here knows which event that is (row 37). - if let Some((map, tile)) = self.machine.take_pushed() { + while 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 @@ -518,7 +518,7 @@ impl MacroPalette for PokemonPalette { let _ = self.machine.take_reached(); // 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(); + while self.machine.take_pushed().is_some() {} 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 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 1c3667f..aaf306a 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 @@ -541,6 +541,16 @@ struct PendingTalk { at: Tile, } +/// What a macro the cartridge ended by taking the joypad earned, held until the cartridge gives +/// the joypad back ([`MacroMachine::pending_push`], row 58). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PendingPush { + /// The tile the fly was driven off, for the pushed ledger (row 37). + tile: Option<(u8, Tile)>, + /// The target the macro was aimed at, for the blocked ledger (section 12.4). + target: Option<(u8, TargetKey)>, +} + /// A walk the frame cap cut short, as the next start needs it. /// /// Keyed by the target rather than by the macro, because that is what a resumed walk *is*: the @@ -589,7 +599,7 @@ pub struct MacroMachine { /// and the blocked ledger could only say it about the target the walk was aimed at. Recorded /// 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)>, + pushed_tile: Vec<(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`). @@ -637,6 +647,21 @@ pub struct MacroMachine { /// the next press will not undo. One hold of frames is the window, because that is how long /// the fly has to choose again; anything later and something else happened in between. pending_answer: Option, + /// A macro the cartridge ended by taking the joypad, whose ledger entries wait for the + /// cartridge to give it back (row 58). + /// + /// Section 12.4 and row 37 read "the cartridge took the joypad" as the cartridge *refusing* + /// the step -- the Viridian gate's "This is private property!" and the walk back -- and wrote + /// the target into the blocked ledger and the tile into the pushed one on the spot. A trainer + /// who sees the fly takes the joypad the same way: the "!", the walk up, the challenge. In the + /// Pewter Gym that cost BROCK: the fly walked toward him past the Jr. Trainer's line of sight, + /// the trainer's walk ended the macro, BROCK went into the blocked ledger for ten brain + /// minutes and the tile the walk set out from into the pushed one for the session -- and a fly + /// that lost the battle and walked back found the leader excluded and the way out on the pad. + /// What the cartridge does when it gives the joypad back is what says which it was: back in + /// the overworld is a refusal and is written as one; a battle is a battle, and nothing about + /// the target or the ground is learned from it. + pending_push: Vec, /// A finished `TALK`'s target, waiting to be taken into the session's talked ledger. /// /// The machine records rather than keeps: the ledger is the driver's @@ -664,12 +689,13 @@ impl MacroMachine { blocked: Vec::new(), reached: None, exhausted: None, - pushed_tile: None, + pushed_tile: Vec::new(), refused_at: None, timed_out: None, resume: VecDeque::new(), pending_talk: None, pending_answer: None, + pending_push: Vec::new(), talked: None, rng: if seed == 0 { 1 } else { seed }, } @@ -903,8 +929,11 @@ impl MacroMachine { } /// The tile a scripted push-back earned, taken rather than read (row 37). + /// + /// Call it until it answers `None`: the entries a script held back are written together when + /// it gives the joypad back (row 58). pub fn take_pushed(&mut self) -> Option<(u8, Tile)> { - self.pushed_tile.take() + if self.pushed_tile.is_empty() { None } else { Some(self.pushed_tile.remove(0)) } } /// Where the last `no route` or `precondition` refusal happened, taken rather than read @@ -936,7 +965,7 @@ impl MacroMachine { self.reached = None; // 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.pushed_tile.clear(); self.exhausted = None; self.refused_at = None; self.timed_out = None; @@ -948,6 +977,8 @@ impl MacroMachine { self.pending_talk = None; // Nor is it a prompt reopening: the frames the answer was made in are being thrown away. self.pending_answer = None; + // Nor the cartridge refusing a step: the frames it happened in are being thrown away too. + self.pending_push.clear(); } /// Whether the fly is standing somewhere other than where the running macro began. @@ -976,6 +1007,7 @@ impl MacroMachine { /// - the fly answered `NO` — not talked, and that one is decided in [`MacroMachine::finish`]. pub fn observe_frame(&mut self, state: &mut dyn MacroState) { self.observe_answer(state); + self.observe_push(state); let Some(pending) = self.pending_talk else { return }; if state.scripted() { self.pending_talk = None; @@ -1002,6 +1034,34 @@ impl MacroMachine { } } + /// One frame after the cartridge took the joypad from a macro: decide what it was (row 58). + /// + /// Back in the overworld with the buttons the fly's again: a refusal, written exactly as + /// section 12.4 and row 37 always wrote it. A battle: a trainer's challenge, and it teaches the + /// ledgers nothing. Anything else -- the text, the walk, the frames between -- is still the + /// cartridge's, and the decision waits. + fn observe_push(&mut self, state: &mut dyn MacroState) { + if self.pending_push.is_empty() { + return; + } + match class(state.scene()) { + Class::Battle | Class::ForcedSwitch => self.pending_push.clear(), + Class::Overworld => { + // Every macro the script ended while it held the joypad -- the walk it interrupted + // and any press made into its text -- in the order they ended. + for pending in std::mem::take(&mut self.pending_push) { + if let Some(tile) = pending.tile { + self.pushed_tile.push(tile); + } + if let Some(target) = pending.target { + self.blocked.push(target); + } + } + } + _ => {} + } + } + /// Whether the answer still standing on `map` is a `NO` to a readable prompt: a declined offer /// rather than a conversation walked through (section 12.20). fn declined_out_of(&self, map: u8) -> bool { @@ -1127,14 +1187,18 @@ impl MacroMachine { // entrance -- with no window, in the middle of the town. A dozen of those fenced // the fly into a pocket no walk could leave. The tile a walk last stood the fly on // is its own record of where the cartridge took over. - if let Some(player) = at { + // + // Held until the cartridge gives the joypad back, which is what says whether this + // was a refusal or a trainer walking up (row 58, [`MacroMachine::observe_push`]). + let tile = at.map(|player| { let current = Tile::new(player.x, player.y); let tile = match active.plan.front() { Some(Step::Walk(walk)) => walk.expect.unwrap_or(current), _ => active.from.unwrap_or(current), }; - self.pushed_tile = Some((player.map, tile)); - } + (player.map, tile) + }); + self.pending_push.push(PendingPush { tile, target: None }); } let (closer, stalled) = walk_flags(&active); // A walk the cap cut short keeps its route for the next hold; any other ending means @@ -1181,8 +1245,15 @@ impl MacroMachine { // target's own fact, not the world's, so it is excluded for the window like // any other refusal. Without it the gate was walked into once per hold for // ever, because every macro that hit it ended `Done`. - if pushed && let Some(entry) = active.target { - self.blocked.push(entry); + // + // Held with the tile above, and for the same reason: a trainer's walk up to the + // fly takes the joypad exactly as the gate's walk back does, and only what the + // cartridge does next tells them apart (row 58). + if pushed + && let Some(entry) = active.target + && let Some(pending) = self.pending_push.last_mut() + { + pending.target = Some(entry); } // A `GO FRONTIER` whose press faced new ground it could not stand on: `Done`, // because facing it is what the arrival promises, and excluded, because the 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 5aee4e0..097ba80 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 @@ -18,7 +18,7 @@ use crate::adapter::PlaceKind; use crate::emulator::buttons; use super::cartridge::{ - BLOCKED_MINUTES_DEFAULT, CHEAPEST_PURCHASE, Edge, ExitId, FACINGS, MacroState, Objective, + BLOCKED_MINUTES_DEFAULT, CHEAPEST_PURCHASE, Edge, ExitId, FACINGS, LAST_MAP, MacroState, Objective, TalkTarget, TargetKey, TargetLedger, Targets, Tile, battle_entry, button, item, price, }; use super::geography::Amenity; @@ -89,6 +89,8 @@ struct World { warps: Vec, connections: Connections, npcs: Vec, + /// Sprites the cartridge is not drawing only because they are off the screen (row 58). + offscreen: Vec, signs: Vec, list: List, @@ -223,6 +225,7 @@ impl World { warps: Vec::new(), connections: Connections::default(), npcs: Vec::new(), + offscreen: Vec::new(), signs: Vec::new(), list: List::None, cursor: 0, @@ -636,6 +639,10 @@ impl GameState for World { self.npcs.clone() } + fn offscreen_npcs(&mut self) -> Vec { + self.offscreen.clone() + } + fn signs(&mut self) -> Vec { self.signs.clone() } @@ -811,6 +818,13 @@ fn drive( // The loop's own bookkeeping, so a test sees what the next decision would see: whatever the // finish earned goes into the session's ledgers, which is `PokemonPalette::record_talk`'s job // in the sim loop and this line's here. + settle(machine, world); + Ok(machine.outcome().expect("a finished macro has an outcome").1) +} + +/// `PokemonPalette::record_talk`, over the fixture: whatever the machine has earned goes into the +/// session's ledgers. +fn settle(machine: &mut MacroMachine, world: &mut World) { while let Some((map, target)) = machine.take_blocked() { world.targets.record_blocked(map, target); } @@ -823,11 +837,25 @@ fn drive( if let Some((map, target)) = machine.take_reached() { world.targets.record_reached(map, target); } + while let Some((map, tile)) = machine.take_pushed() { + assert_eq!(map, world.map); + world.pushes.insert(tile); + } if let Some((map, target)) = machine.take_talked() { assert_eq!(map, world.map); world.talked.insert(target); } - Ok(machine.outcome().expect("a finished macro has an outcome").1) +} + +/// The cartridge gives the joypad back in the overworld: one frame of it, observed, and whatever +/// it decided taken into the ledgers (row 58). +fn hand_back(machine: &mut MacroMachine, world: &mut World) { + world.scene = Scene::Overworld; + world.scripted = false; + world.scripted_at = None; + world.switch = None; + machine.observe_frame(world); + settle(machine, world); } /// A palette of exactly one button, for a script whose macro no scene binds any more. @@ -3510,7 +3538,11 @@ fn a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to() { let north = TargetKey::Exit(ExitId::Edge(Edge::North)); assert!(on_the_pad(&mut world, MacroKind::GoRoute)); - assert_eq!(run(&mut world, MacroKind::GoRoute), Ok(MacroAbort::Done)); + let mut machine = MacroMachine::new(0x1234_5678); + assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done)); + // The gate's text is still up: the cartridge has not given the joypad back (row 58). + assert!(!world.targets.blocked(world.map, north), "nothing decided inside the script"); + hand_back(&mut machine, &mut world); assert!( world.targets.blocked(world.map, north), "the road the cartridge refused is excluded for the window" @@ -5023,10 +5055,13 @@ fn an_escorted_walk_walls_the_tile_it_reached_not_the_one_it_set_out_from() { let mut machine = MacroMachine::new(1); let _ = run_with(&mut machine, &mut world, MacroKind::GoRoute); - let (map, tile) = machine.take_pushed().expect("the script moved the fly: a push-back"); - assert_eq!(map, maps::PEWTER_CITY); - assert_ne!(tile, Tile::new(3, 6), "not the tile the walk set out from"); - assert_eq!(tile, world.player, "the tile the walk had reached when the script took over"); + // Row 58: written when the cartridge gives the joypad back in the overworld. + let reached = world.player; + hand_back(&mut machine, &mut world); + let pushed: Vec = world.pushes.iter().copied().collect(); + assert_eq!(pushed.len(), 1, "the script moved the fly: a push-back"); + assert_ne!(pushed[0], Tile::new(3, 6), "not the tile the walk set out from"); + assert_eq!(pushed[0], reached, "the tile the walk had reached when the script took over"); } /// The push-back writes the ledger, and it writes the *tile* rather than the target. @@ -5042,10 +5077,13 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() { let mut machine = MacroMachine::new(1); let _ = run_with(&mut machine, &mut world, MacroKind::Talk); - let pushed = machine.take_pushed(); + assert!(world.pushes.is_empty(), "nothing is decided while the cartridge holds the joypad"); + // Row 58: the ledger is written when the cartridge gives the joypad back in the overworld, + // which is what tells the gate's walk back from a trainer's walk up. + hand_back(&mut machine, &mut world); assert_eq!( - pushed, - Some((world.map, Tile::new(3, 3))), + world.pushes.iter().copied().collect::>(), + vec![Tile::new(3, 3)], "the tile the macro was standing on, not the person it was facing" ); } @@ -5198,3 +5236,123 @@ fn a_completed_heal_writes_the_nurse_into_the_talked_ledger() { assert_eq!(center.player, Tile::new(3, 3)); assert!(!precondition(MacroKind::Talk, &mut center)); } + +// --------------------------------------------------------------------------------------------- +// Row 58: the gym door, in and out +// --------------------------------------------------------------------------------------------- + +/// The Pewter Gym as the fly finds it on its doormat: the guide on screen and talked to, the +/// leader and the Jr. Trainer up the room and not drawn. +fn pewter_gym_doormat() -> World { + let mut world = World::room().at(4, 13); + world.map = maps::PEWTER_GYM; + world.size = MapSize { width: 10, height: 14 }; + world.facing = Facing::Up; + world.warps = vec![ + Warp { x: 4, y: 13, destination_warp: 3, destination_map: LAST_MAP }, + Warp { x: 5, y: 13, destination_warp: 3, destination_map: LAST_MAP }, + ]; + world.npcs = vec![Npc { slot: 3, picture: 1, x: 7, y: 10, facing: Facing::Down }]; + world.offscreen = vec![ + Npc { slot: 1, picture: 2, x: 4, y: 1, facing: Facing::Down }, + Npc { slot: 2, picture: 3, x: 3, y: 6, facing: Facing::Right }, + ]; + world.talked.insert(TalkTarget::Sprite(3)); + // Pewter's errands are paid, as they were live: the objective is the rung's own place. + world.areas.insert((Amenity::Mart, maps::PEWTER_CITY)); + world.areas.insert((Amenity::Center, maps::PEWTER_CITY)); + world.objective = Some(Objective { + map: world.map, + tile: None, + warp: None, + edge: None, + target: Some(PlaceKind::Person), + }); + world +} + +#[test] +fn the_rungs_people_are_in_the_room_when_the_screen_does_not_show_them() { + // Row 58, live for twenty-five minutes: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` straight + // back out, ~200 macro starts per ten brain minutes and no reward at all. From the doormat the + // cartridge draws only the guide, who had been talked to, so the rung's own list was empty: + // `GO OBJECTIVE` had nothing to aim at and `GO OUT` -- whose candidates 12.5 withholds only + // while the rung's person is in the room -- was the pad. BROCK was twelve rows up. + let mut world = pewter_gym_doormat(); + let targets = super::palette::objective_targets(&mut world); + assert!( + targets.contains(&(Tile::new(4, 1), TalkTarget::Sprite(1))), + "the leader is one of the rung's people: {targets:?}" + ); + assert!(on_the_pad(&mut world, MacroKind::GoObjective), "there is someone to walk to"); + assert!(!on_the_pad(&mut world, MacroKind::GoOut), "and the room is not left while he is in it"); + + // What the base saw, for the record: the drawn sprites alone leave nothing. + world.offscreen.clear(); + assert!(super::palette::objective_targets(&mut world).is_empty()); + assert!(!on_the_pad(&mut world, MacroKind::GoObjective)); + assert!(on_the_pad(&mut world, MacroKind::GoOut), "the undo pair's inside half"); +} + +#[test] +fn only_the_rung_reads_people_off_the_screen() { + // A sprite outside the window may be a toggleable object the cartridge has switched off, and + // the two read alike from here (`state::offscreen_npcs`). The rung's list is the one reader: + // `GO NPC`, `TALK` and the objects are what they were. + let mut world = pewter_gym_doormat(); + world.objective = None; + assert!(super::palette::untalked_people(&mut world).is_empty(), "`GO NPC` sees what is drawn"); + world.objective = Some(Objective { + map: world.map, + tile: None, + warp: None, + edge: None, + target: Some(PlaceKind::Object), + }); + assert!( + super::palette::objective_targets(&mut world).is_empty(), + "an item ball out of sight and one picked up read alike, so objects are not guessed at" + ); +} + +#[test] +fn facing_one_of_the_rungs_people_is_the_arrival() { + // A gym names three people and 12.5's "leave out the one ahead" was written for one: in front + // of the leader, `GO OBJECTIVE` still had the Jr. Trainer to walk to, and at the trainer it had + // the leader. The walk is done when any of them is ahead, and `TALK` is the press. + let mut world = pewter_gym_doormat().at(4, 2); + world.facing = Facing::Up; + world.npcs = vec![Npc { slot: 1, picture: 2, x: 4, y: 1, facing: Facing::Down }]; + world.offscreen = vec![Npc { slot: 2, picture: 3, x: 3, y: 6, facing: Facing::Right }]; + assert!(on_the_pad(&mut world, MacroKind::Talk)); + assert!(!on_the_pad(&mut world, MacroKind::GoObjective), "no walk left while facing him"); + + world.facing = Facing::Left; + assert!(on_the_pad(&mut world, MacroKind::GoObjective), "turned away, the walk is back"); +} + +#[test] +fn a_trainer_walking_up_teaches_the_ledgers_nothing() { + // The other half of the gym. A walk toward the leader crossed the Jr. Trainer's line of sight; + // the trainer's "!" and walk up took the joypad, which 12.4 reads as the cartridge refusing + // the step, so BROCK went into the blocked ledger for ten brain minutes and the tile into the + // pushed one for the session. What the cartridge does when it gives the joypad back is what + // tells a refusal from a challenge. + let mut world = World::room().at(3, 3); + world.map = 0x00; + world.connections = Connections { north: true, south: false, east: false, west: false }; + world.switch = Some((4, Scene::Dialog)); + world.scripted_at = Some(4); + let north = TargetKey::Exit(ExitId::Edge(Edge::North)); + let mut machine = MacroMachine::new(0x1234_5678); + assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done)); + + // The challenge closes into a battle. + world.scene = Scene::Battle { own_turn: false, forced_switch: false }; + machine.observe_frame(&mut world); + settle(&mut machine, &mut world); + // And the battle ends back in the overworld: nothing was refused. + hand_back(&mut machine, &mut world); + assert!(!world.targets.blocked(world.map, north), "a challenge is not the road refusing"); + assert!(world.pushes.is_empty(), "and the ground is as walkable as it was"); +}