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 28d9fe6..67a7b67 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 @@ -27,7 +27,8 @@ use super::executor::{ WALK_FRAME_CEILING, walk_budget, }; use super::palette::{ - MacroId, MacroKind, Palette, SLOTS, amenity_goals, errand, healthiest_other, heal_goals, + MacroId, MacroKind, Palette, SLOTS, amenity_goals, answer_key, errand, facing_nurse, + healthiest_other, heal_goals, nurse_prompt, rested_nurse, listing, losing, move_slot_bound, objective_goals, party_needs_rest, party_rested, poke_sprite, precondition, throw_slot, untalked_objects, untalked_people, ways, }; @@ -163,6 +164,12 @@ struct World { /// A frame at which the cartridge heals the party, which is what a Pokémon Center does while /// its text box is open (`docs/design/macros.md` section 13). heal_at: Option, + /// Whether the two-option YES/NO box is the thing on screen ([`MacroState::yes_no_prompt`]). + /// + /// A field rather than a shape of the `list`, because on the cartridge it is a *drawn box* + /// beside a cursor the game never clears, and what the palette asks is only "is a choice + /// open" (section 12.12). + prompt: bool, /// Whether the cartridge is driving the player right now ([`MacroState::scripted`]). scripted: bool, /// A frame at which the cartridge takes the joypad, which is what the Viridian gate does. @@ -236,6 +243,7 @@ impl World { pending: None, switch: None, heal_at: None, + prompt: false, scripted: false, scripted_at: None, pulses: Vec::new(), @@ -339,6 +347,18 @@ impl World { world } + /// [`World::center`] with the fly at the counter facing the nurse, mid-conversation. + /// + /// The rung-10 state (`infra/docs/macros-traps.md` row 41): map `0x3a` at (3, 3) facing up, + /// a text box open, the nurse two tiles away over the counter at (3, 1). + fn at_the_nurse() -> Self { + let mut world = Self::center(); + world.player = Tile::new(3, 3); + world.facing = Facing::Up; + world.scene = Scene::Dialog; + world + } + fn at(mut self, x: u8, y: u8) -> Self { self.player = Tile::new(x, y); self @@ -638,6 +658,12 @@ impl MacroState for World { self.scripted } + /// 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 { + self.prompt && self.scene == Scene::Dialog + } + fn shop_stock(&mut self) -> Vec { self.stock.clone() } @@ -3892,6 +3918,16 @@ fn heal_is_on_the_centres_pad_only_while_the_party_needs_it() { assert!(!precondition(MacroKind::Heal, &mut center)); assert!(!on_the_pad(&mut center, MacroKind::Heal)); + // The rung-10 party, as the live checkpoint of 2026-09-22 reads it: one Pokemon, 70 of 70, + // healthy (`infra/docs/macros-traps.md` row 41). `HEAL` was **not** what looped there -- its + // precondition reads the live party and answers no, and the survey confirmed it on the + // cartridge. So this is the assertion that the loop was never the heal's. + let mut rung10 = World::center(); + rung10.mons = vec![Mon { hp: 70, max_hp: 70, ..mon(0, 70, 70, &[(33, 30)]) }]; + assert!(!party_needs_rest(&mut rung10)); + assert!(!precondition(MacroKind::Heal, &mut rung10)); + assert!(!on_the_pad(&mut rung10, MacroKind::Heal)); + // Hurt. center.mons[0].hp = 9; assert!(precondition(MacroKind::Heal, &mut center)); @@ -4565,3 +4601,149 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() { mod map_aware; mod shop_purchase; + +// --------------------------------------------------------------------------------------------- +// Section 12.12: the nurse's box (row 41) +// --------------------------------------------------------------------------------------------- + +#[test] +fn talk_is_off_the_pad_at_a_nurse_the_party_has_no_use_for() { + // The overworld frame the ring starts from: at the counter, facing the nurse, party full. + let mut center = World::center().at(3, 3); + center.facing = Facing::Up; + assert!(facing_nurse(&mut center), "the nurse is the thing ahead, over the counter"); + assert!(rested_nurse(&mut center)); + assert!(!precondition(MacroKind::Talk, &mut center)); + assert!(!on_the_pad(&mut center, MacroKind::Talk)); + + // Hurt, and she is worth talking to again -- the conversation now does something. + center.mons[0].hp = 4; + assert!(!rested_nurse(&mut center)); + assert!(precondition(MacroKind::Talk, &mut center)); + assert!(on_the_pad(&mut center, MacroKind::Talk)); + + // Statused at full HP counts as needing her, exactly as `HEAL`'s own precondition does. + center.mons[0].hp = center.mons[0].max_hp; + center.mons[0].status = Status::Poison; + assert!(precondition(MacroKind::Talk, &mut center)); + + // And nobody else in the game is narrowed by this: an ordinary person on the same map is + // still `TALK`'s whatever the party reads. + let mut villager = World::center().at(3, 3); + villager.facing = Facing::Up; + villager.npcs = vec![Npc { slot: 1, picture: 1, x: 3, y: 2, facing: Facing::Down }]; + villager.counters.clear(); + villager.walls.clear(); + assert!(!facing_nurse(&mut villager)); + assert!(precondition(MacroKind::Talk, &mut villager), "a full party is not a reason to ignore a person"); +} + +#[test] +fn the_nurses_prompt_offers_only_the_answer_that_changes_something() { + let mut center = World::at_the_nurse(); + center.prompt = true; + assert!(nurse_prompt(&mut center)); + + // Full and healthy: the offer is for nothing, so `NO` is the answer and `YES` is not on the + // pad. `NEXT` is off it too -- an A press at a two-option box *is* `YES` (12.10). + assert_eq!(names(&plan::plan_for(Scene::Dialog, &mut center)), ["NO"]); + + // Hurt: `YES` is the answer, and `NO` is the one that changes nothing. + center.mons[0].hp = 4; + assert_eq!(names(&plan::plan_for(Scene::Dialog, &mut center)), ["YES"]); + + // A plain text box, which is forty-five of the nurse's forty-six frames, keeps all three: A + // and B both advance one and there is no choice for `NEXT` to be the wrong name for. + center.prompt = false; + assert_eq!(names(&plan::plan_for(Scene::Dialog, &mut center)), ["NEXT", "YES", "NO"]); +} + +#[test] +fn a_readable_prompt_that_is_not_the_nurses_keeps_both_answers_and_loses_next() { + // Red draws a two-option box for a dozen scripts and only the nurse's is an offer about the + // party, so nothing else is narrowed by the party: both answers, and no `NEXT`. + let mut world = World::room(); + world.scene = Scene::Dialog; + world.prompt = true; + assert!(!nurse_prompt(&mut world)); + assert_eq!(names(&plan::plan_for(Scene::Dialog, &mut world)), ["YES", "NO"]); +} + +#[test] +fn a_yes_no_box_that_reopens_unchanged_takes_that_answer_off_the_pad() { + // Section 12.12's general rule, away from the nurse: the answer completed, the fly is on the + // tile it answered from, and the same prompt is up again -- so the press did nothing, which + // is section 12.2's trap, and the answer joins the blocked ledger for its window. + let mut world = World::room(); + world.scene = Scene::Dialog; + world.prompt = true; + assert_eq!(names(&plan::plan_for(Scene::Dialog, &mut world)), ["YES", "NO"]); + + assert_eq!(run(&mut world, MacroKind::Yes).unwrap(), MacroAbort::Done); + let key = answer_key(&mut world, true).expect("a loaded map has a tile"); + assert!(world.targets.blocked(world.map, key), "the answer that changed nothing"); + assert_eq!( + names(&plan::plan_for(Scene::Dialog, &mut world)), + ["NO"], + "the other answer is still there, which is what ends the ring" + ); + + // And `NO` is not excluded by `YES`'s entry: one answer, one key. + let no = answer_key(&mut world, false).expect("a loaded map has a tile"); + assert!(!world.targets.blocked(world.map, no)); +} + +#[test] +fn a_prompt_that_does_not_come_back_excludes_nothing() { + // The other half of the same rule: an answer that settled the box is an answer worth making + // again. Nothing is excluded, because nothing looped. + let mut world = World::room(); + world.scene = Scene::Dialog; + world.prompt = true; + // The box closes on the frame after the press, which is what answering it does. + world.switch = Some((2, Scene::Overworld)); + assert_eq!(run(&mut world, MacroKind::Yes).unwrap(), MacroAbort::Done); + let key = answer_key(&mut world, true).expect("a loaded map has a tile"); + assert!(!world.targets.blocked(world.map, key)); +} + +#[test] +fn a_declined_heal_writes_the_nurse_into_the_talked_ledger() { + // 12.4's rule is "the fly said no, so the thing is still on offer", and for one person in Red + // that is wrong: the pad only ever offers `NO` at her prompt when the party is already full, + // so declining is the errand's end rather than a conversation postponed. + let mut center = World::at_the_nurse(); + center.prompt = true; + assert!(party_rested(&mut center)); + + assert_eq!(run(&mut center, MacroKind::No).unwrap(), MacroAbort::Done); + assert!(center.talked.contains(&TalkTarget::Sprite(1)), "the nurse: {:?}", center.talked); + + // So `TALK` is off the pad there even if the party is hurt later: the ledger is the record + // that this run has had her conversation. + center.scene = Scene::Overworld; + center.mons[0].hp = 4; + assert!(!precondition(MacroKind::Talk, &mut center)); +} + +#[test] +fn a_completed_heal_writes_the_nurse_into_the_talked_ledger() { + let mut center = World::center(); + center.mons[0].hp = 3; + center.switch = Some((200, Scene::Dialog)); + center.heal_at = Some(240); + + assert_eq!(run(&mut center, MacroKind::Heal).unwrap(), MacroAbort::Done); + assert!(party_rested(&mut center)); + assert!( + center.talked.contains(&TalkTarget::Sprite(1)), + "a completed heal has had the conversation: {:?}", + center.talked + ); + + // And `TALK` cannot reopen it. The reached window would expire in ten brain minutes and offer + // the fly the same forty-six text frames again; the talked entry is for the session. + center.scene = Scene::Overworld; + assert_eq!(center.player, Tile::new(3, 3)); + assert!(!precondition(MacroKind::Talk, &mut center)); +} diff --git a/services/flysim/crates/flysim/tests/rom_macros_mode.rs b/services/flysim/crates/flysim/tests/rom_macros_mode.rs index 8ba9b5c..b7e3176 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -59,6 +59,8 @@ const VIRIDIAN_MART: u32 = 0x2a; /// Route 2's southern forest gate and the forest north of it, which is rung 9's own road. const VIRIDIAN_FOREST_SOUTH_GATE: u32 = 0x32; const VIRIDIAN_FOREST: u32 = 0x33; +/// Pewter City's Pokemon Center, which is the room the rung-10 nurse loop was inside. +const PEWTER_POKECENTER: u32 = 0x3a; /// The upper floor of the Pewter museum, which is the building the rung-10 stall was inside. const MUSEUM_2F: u32 = 0x35; /// The forest's *northern* gate, which is the first hop from the forest toward Pewter @@ -241,6 +243,23 @@ struct Run { longest_menu_back_alternation: u32, menu_alternation: u32, last_start: Option<&'static str>, + /// Frames the run spent in a `Scene::Dialog`, and of those, frames a readable YES/NO prompt + /// was open (section 12.12). + /// + /// The two numbers that name row 41: 62,804 of the hunt's 71,673 frames were one text box, and + /// the survey found the **prompt** on one frame of every forty-six. A `NEXT` and a `YES` that + /// are the same press live in the difference. + dialog_frames: u32, + prompt_frames: u32, + /// Whether `NEXT` was ever on the pad while a readable YES/NO prompt was open. + /// + /// 12.10's rule in a dialog: an A press at a two-option box confirms the option the cursor is + /// on, which is what `YES` is, so the two are one press under two names. `false` is the claim. + next_on_a_prompt: bool, + /// Whether `TALK` was ever on the pad while the fly faced a nurse the party had no use for. + /// + /// The door into the ring (section 12.12). `false` is the claim. + talk_at_a_rested_nurse: bool, } impl Run { @@ -332,6 +351,10 @@ impl Run { blocked_where: std::collections::BTreeSet::new(), macros_on_the_first_map: 0, longest_menu_back_alternation: 0, + dialog_frames: 0, + prompt_frames: 0, + next_on_a_prompt: false, + talk_at_a_rested_nurse: false, menu_alternation: 0, last_start: None, } @@ -428,6 +451,10 @@ impl Run { blocked_where: std::collections::BTreeSet::new(), macros_on_the_first_map: 0, longest_menu_back_alternation: 0, + dialog_frames: 0, + prompt_frames: 0, + next_on_a_prompt: false, + talk_at_a_rested_nurse: false, menu_alternation: 0, last_start: None, } @@ -574,6 +601,20 @@ impl Run { flybrain_gb::pokemon_red::macros::MacroState::shop_stock(&mut state) } + /// Whether the two-option YES/NO box is drawn, through the accessor the palette reads + /// (section 12.12). + fn yes_no_prompt(&mut self) -> bool { + flybrain_gb::pokemon_red::state::yes_no_prompt(&mut self.gb) + } + + /// Whether the fly faces a Pokemon Center nurse with a party that does not need her. + fn rested_nurse(&mut self) -> bool { + let ledger = AdapterLedger(&self.adapter); + let mut state = + flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger); + flybrain_gb::pokemon_red::macros::palette::rested_nurse(&mut state) + } + /// Whether every party member reads full HP with no status: the end of a `HEAL`. fn party_rested(&mut self) -> bool { let party = flybrain_gb::pokemon_red::state::party(&mut self.gb); @@ -748,6 +789,24 @@ impl Run { if self.route.last() != Some(&map) && map != u32::MAX { self.route.push(map); } + // Section 12.12's two frame counters and its two pad rules, asked of the frame the pad + // was dealt for -- the dialog's, exactly as 12.10 asks the battle rules of the battle's. + if self.layer.scene_name() == "dialog" { + self.dialog_frames += 1; + let dealt = self.layer.bound_channels(); + if self.yes_no_prompt() { + self.prompt_frames += 1; + if dealt.iter().any(|channel| channel.as_str() == "macro_next") { + self.next_on_a_prompt = true; + } + } + } + if self.layer.scene_name() == "overworld" + && self.rested_nurse() + && self.layer.bound_channels().iter().any(|channel| channel.as_str() == "macro_talk") + { + self.talk_at_a_rested_nurse = true; + } // The pad the *next* frame will choose from, for the maps that have been accused of // dealing one button. Only the overworld: a warp in flight reads `unknown` and a text box // is a pad of its own. @@ -2017,3 +2076,95 @@ fn the_fly_leaves_the_pewter_building_from_the_rung_ten_checkpoint() { run.blocked ); } + +/// The rung-10 Pokemon Center checkpoint, or `None` to skip. +fn center_checkpoint() -> Option { + std::env::var_os("FLY_CENTER_CHECKPOINT").map(|path| { + flysim::store::load(std::path::Path::new(&path)) + .expect("the checkpoint should be a FLYSIM01 envelope") + }) +} + +/// From the rung-10 Pokemon Center checkpoint: the fly leaves the centre and stops answering YES. +/// +/// **What was live** (2026-09-22, v0.4.4, rank 10 PEWTER CITY): the fly on **map 0x3a at (3, 3)**, +/// facing the nurse over her counter, and since the 09:39 restart the macro starts were `YES` +/// **2,142**, `TALK` 107, `GO FRONTIER` 26, `BACK` 24, the event log ending `YES start/done` for +/// ever. This is row 41, first measured in the rung-9 trap hunt and named as the next trap by +/// 12.11. +/// +/// **What the survey found** (`infra/docs/macros-traps.md` row 41, and +/// `examples/scene_probe.rs`'s `FLY_PROBE_CATCH=nurse`): the nurse's conversation is a ring of +/// **forty-six A presses** -- welcome, the offer, the YES/NO box on **one** frame of the +/// forty-six, "OK. We'll need your POKeMON.", the machine, "fighting fit!", "We hope to see you +/// again!", the box closes for a single frame, and the next A press opens the whole thing again. +/// The party read **70/70 and healthy** throughout, so every press of it changed nothing, and +/// `HEAL` was never in it: its precondition reads the live party and answers no. What was on the +/// pad was the dialog's `NEXT`, `YES`, `NO` -- two names for one A press -- and `TALK` to get back +/// in, whose ledger entry was read one tile shorter than its own precondition and so was never +/// written. +/// +/// The claims, none of them about where the fly goes next: +/// +/// - `YES` starts **under five** in the whole run, against 1,278 in the rung-9 hunt from the same +/// room. Not zero: the fly may legitimately answer a hurt party's prompt. +/// - `NEXT` is on **no** pad while a readable YES/NO prompt is open (12.10 in a dialog). +/// - `TALK` is on **no** pad while the fly faces a nurse the party has no use for. +/// - the fly **leaves map 0x3a** on a bounded number of macros. +/// +/// ```sh +/// FLY_ROM=/path/to/pokemon-red.gb \ +/// FLY_CENTER_CHECKPOINT=.local/checkpoints/release-rank10-pokecenter.checkpoint \ +/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture +/// ``` +#[test] +fn the_fly_leaves_the_pokemon_center_from_the_rung_ten_checkpoint() { + let rom = skip_without_rom!(); + let Some(checkpoint) = center_checkpoint() else { + eprintln!("skipped: no FLY_CENTER_CHECKPOINT"); + return; + }; + let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint); + let from = run.map(); + assert_eq!(from, PEWTER_POKECENTER, "the checkpoint is the room the stream stalled in"); + // The premise of the whole trap: there was nothing to heal. + assert!(run.party_rested(), "the checkpoint's party is already full and healthy"); + + let mut left = None; + for frame in 0..120_000u32 { + run.frame(); + if left.is_none() && run.map() != from { + left = Some(frame); + } + } + eprintln!( + "from map {from:#04x} in {:.1} brain minutes: route {:?}, macros {:?}, dialog frames {} \ + (prompt on {}), blocked {:?}", + run.ms / 60_000.0, + run.route, + run.started, + run.dialog_frames, + run.prompt_frames, + run.blocked + ); + + assert!( + !run.next_on_a_prompt, + "`NEXT` was on the pad at a YES/NO box, where an A press is `YES`" + ); + assert!( + !run.talk_at_a_rested_nurse, + "`TALK` was on the pad at a nurse the party had no use for" + ); + let yes = run.started.get("YES").copied().unwrap_or(0); + assert!(yes < 5, "`YES` started {yes} times: {:?}", run.started); + let Some(left) = left else { + panic!("the fly never left map {from:#04x}: {:?}", run.started) + }; + eprintln!("it left map {from:#04x} on frame {left}"); + assert!( + run.macros_on_the_first_map < 400, + "leaving the centre cost {} macros", + run.macros_on_the_first_map + ); +}