macros: tests for the nurse's box

Unit: HEAL is off a full party's pad, including the rung-10 party's own
numbers, which is the assertion that the loop was never the heal's; TALK
is off a rested nurse's pad and on a hurt one's, and an ordinary
villager is unaffected; her prompt deals one answer and a plain box still
deals three; a readable prompt that is not hers deals both answers and no
NEXT; an answer whose prompt reopens is excluded and one that settled the
box is not; a completed heal and a declined prompt both retire her.

ROM-gated from the live checkpoint (FLY_CENTER_CHECKPOINT): the fly
leaves map 0x3a on a bounded number of macros, YES starts stay under
five, NEXT is on no pad while a prompt is readable, and TALK is on no pad
at a rested nurse. The run also counts dialog and prompt frames, which is
what makes the forty-six-to-one ratio visible.
This commit is contained in:
acamilo 2026-09-22 10:39:15 +00:00
parent 70a2433e25
commit 0bf4b965fb
2 changed files with 334 additions and 1 deletions

View file

@ -27,7 +27,8 @@ use super::executor::{
WALK_FRAME_CEILING, walk_budget, WALK_FRAME_CEILING, walk_budget,
}; };
use super::palette::{ 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, listing, losing, move_slot_bound, objective_goals, party_needs_rest, party_rested,
poke_sprite, precondition, throw_slot, untalked_objects, untalked_people, ways, 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 /// 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). /// its text box is open (`docs/design/macros.md` section 13).
heal_at: Option<u32>, heal_at: Option<u32>,
/// 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`]). /// Whether the cartridge is driving the player right now ([`MacroState::scripted`]).
scripted: bool, scripted: bool,
/// A frame at which the cartridge takes the joypad, which is what the Viridian gate does. /// A frame at which the cartridge takes the joypad, which is what the Viridian gate does.
@ -236,6 +243,7 @@ impl World {
pending: None, pending: None,
switch: None, switch: None,
heal_at: None, heal_at: None,
prompt: false,
scripted: false, scripted: false,
scripted_at: None, scripted_at: None,
pulses: Vec::new(), pulses: Vec::new(),
@ -339,6 +347,18 @@ impl World {
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 { fn at(mut self, x: u8, y: u8) -> Self {
self.player = Tile::new(x, y); self.player = Tile::new(x, y);
self self
@ -638,6 +658,12 @@ impl MacroState for World {
self.scripted 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<u8> { fn shop_stock(&mut self) -> Vec<u8> {
self.stock.clone() 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!(!precondition(MacroKind::Heal, &mut center));
assert!(!on_the_pad(&mut center, MacroKind::Heal)); 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. // Hurt.
center.mons[0].hp = 9; center.mons[0].hp = 9;
assert!(precondition(MacroKind::Heal, &mut center)); 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 map_aware;
mod shop_purchase; 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));
}

View file

@ -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. /// 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_SOUTH_GATE: u32 = 0x32;
const VIRIDIAN_FOREST: u32 = 0x33; 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. /// The upper floor of the Pewter museum, which is the building the rung-10 stall was inside.
const MUSEUM_2F: u32 = 0x35; const MUSEUM_2F: u32 = 0x35;
/// The forest's *northern* gate, which is the first hop from the forest toward Pewter /// 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, longest_menu_back_alternation: u32,
menu_alternation: u32, menu_alternation: u32,
last_start: Option<&'static str>, 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 { impl Run {
@ -332,6 +351,10 @@ impl Run {
blocked_where: std::collections::BTreeSet::new(), blocked_where: std::collections::BTreeSet::new(),
macros_on_the_first_map: 0, macros_on_the_first_map: 0,
longest_menu_back_alternation: 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, menu_alternation: 0,
last_start: None, last_start: None,
} }
@ -428,6 +451,10 @@ impl Run {
blocked_where: std::collections::BTreeSet::new(), blocked_where: std::collections::BTreeSet::new(),
macros_on_the_first_map: 0, macros_on_the_first_map: 0,
longest_menu_back_alternation: 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, menu_alternation: 0,
last_start: None, last_start: None,
} }
@ -574,6 +601,20 @@ impl Run {
flybrain_gb::pokemon_red::macros::MacroState::shop_stock(&mut state) 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`. /// Whether every party member reads full HP with no status: the end of a `HEAL`.
fn party_rested(&mut self) -> bool { fn party_rested(&mut self) -> bool {
let party = flybrain_gb::pokemon_red::state::party(&mut self.gb); 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 { if self.route.last() != Some(&map) && map != u32::MAX {
self.route.push(map); 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 // 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 // dealing one button. Only the overworld: a warp in flight reads `unknown` and a text box
// is a pad of its own. // is a pad of its own.
@ -2017,3 +2076,95 @@ fn the_fly_leaves_the_pewter_building_from_the_rung_ten_checkpoint() {
run.blocked run.blocked
); );
} }
/// The rung-10 Pokemon Center checkpoint, or `None` to skip.
fn center_checkpoint() -> Option<flysim::store::Checkpoint> {
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
);
}