diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs index 734c873..80c5ccf 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs @@ -85,14 +85,17 @@ impl Conversation { } } -/// Samples after the box opens in which `wSpriteIndex` may still be read. +/// Samples after the box opens by which `DisplayTextID` has certainly written its argument. /// -/// `DisplayTextIDInit` sets the font bit a few hundred cycles before `DisplayTextID` copies its -/// argument into `wSpriteIndex`, with no frame in between -- but a frame boundary is wherever the -/// CPU happens to be at the vertical blank, so a sample can land between the two. A stale -/// argument names the previous conversation, which is either not in front of the fly (and does -/// not resolve) or the same thing (and is the same key), so a short window costs nothing. -const OPENING_SAMPLES: u8 = 3; +/// `DisplayTextIDInit` sets the font bit and then loads the font's tiles into VRAM, which takes +/// frames; `DisplayTextID` copies its argument into `wSpriteIndex` only after that. Measured on +/// the cartridge (`tests/rom_engage.rs`, the Viridian Forest north gate): the bit rose on one +/// frame and the argument arrived **twenty frames** later. Until then the byte still holds +/// whatever the *last* text was about -- which may well be the person in front of the fly, from +/// a conversation that did not pay -- so it is not read as this conversation's argument until it +/// has changed, or until this many samples have gone by, after which an unchanged byte means the +/// new text is about the same thing as the last one. More than twice the measured delay. +const ARGUMENT_SETTLED: u8 = 45; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct Armed { @@ -102,12 +105,17 @@ struct Armed { /// The fly had the joypad and was standing still: [`state::controllable`] and a zero /// `wWalkCounter`. The overworld only reads an A press in that state. ready: bool, + /// `wSpriteIndex` before the box opened: the previous text's argument. + stale: u8, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct Opening { map: u8, samples: u8, + stale: u8, + /// The bottom dialogue box has been on screen during this opening. + dialogue: bool, } /// The `talk` rule's per-frame watch. Transient: a restore or a rollback clears it, so a @@ -143,7 +151,9 @@ impl TalkWatch { /// tile it is on now. A script's text opens with the joypad already taken, or on the frame /// a step onto a trigger tile ends; neither is `ready`. /// 2. **It is with the thing in front of the fly.** `DisplayTextID` copies its argument into - /// `wSpriteIndex`: a value up to `wNumSprites` is a sprite slot, and that sprite must stand + /// `wSpriteIndex` once the font is loaded ([`ARGUMENT_SETTLED`] has the timing, and why + /// the byte is read only once it has changed or settled), in the bottom dialogue box -- + /// the start menu is drawn elsewhere. A value up to `wNumSprites` is a sprite slot, and that sprite must stand /// on the tile the player faces -- or one further, across a counter, on a tileset that has /// counter tiles (`IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter`). A larger /// value is a text id, and it must be the text id of the sign on the tile the player faces. @@ -162,13 +172,30 @@ impl TalkWatch { ) -> Option { let open = memory.read8(ram::wFontLoaded) & poke::BIT_FONT_LOADED != 0; if !open { - self.opening = None; + // A box that closes before its argument was ever seen to change: a short text about + // the same thing as the last one. The argument was written before a letter printed, + // so it is this text's; the dialogue box must have been drawn for it. + let argument = memory.read8(ram::wSpriteIndex); + if let Some(opening) = self.opening.take() + && opening.dialogue + && opening.map == map + && let Some(thing) = thing_named(memory, argument) + { + self.pending = Some(Conversation { map, thing }); + } let finished = self .pending .take() .filter(|conversation| conversation.map == map); let ready = state::controllable(memory) && memory.read8(ram::wWalkCounter) == 0; - self.armed = Some(Armed { map, x, y, ready }); + let stale = argument; + self.armed = Some(Armed { + map, + x, + y, + ready, + stale, + }); return finished; } if let Some(armed) = self.armed.take() @@ -179,17 +206,26 @@ impl TalkWatch { && self.pending.is_none() && indoor(memory.read8(ram::wCurMapTileset)) { - self.opening = Some(Opening { map, samples: 0 }); + self.opening = Some(Opening { + map, + samples: 0, + stale: armed.stale, + dialogue: false, + }); } if let Some(opening) = self.opening.as_mut() { opening.samples += 1; + opening.dialogue |= state::text_box(memory).waiting; let map = opening.map; - let expired = opening.samples >= OPENING_SAMPLES; - if let Some(thing) = thing_in_front(memory) { - self.pending = Some(Conversation { map, thing }); - self.opening = None; - } else if expired { + let argument = memory.read8(ram::wSpriteIndex); + if argument != opening.stale || opening.samples >= ARGUMENT_SETTLED { + // One reading, whichever way it goes: the argument this text was opened with, + // and only while the dialogue box is what is drawn (not the start menu's). + let dialogue = state::text_box(memory).waiting; self.opening = None; + if dialogue && let Some(thing) = thing_named(memory, argument) { + self.pending = Some(Conversation { map, thing }); + } } } None @@ -219,8 +255,11 @@ fn is_item_ball(memory: &mut dyn MemoryReader, slot: u8) -> bool { } /// `DisplayTextID`'s argument, if it names something the player is facing. -fn thing_in_front(memory: &mut dyn MemoryReader) -> Option { - let argument = memory.read8(ram::wSpriteIndex); +/// +/// The caller also asks for the bottom dialogue box ([`state::text_box`]'s `waiting`): +/// `DisplayTextIDInit` draws it for every text id but the start menu's, which it draws at the top +/// right instead. +fn thing_named(memory: &mut dyn MemoryReader, argument: u8) -> Option { if argument == 0 { // TEXT_START_MENU. return None; diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs index 3878eb3..dd43822 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs @@ -1401,17 +1401,59 @@ impl Fixture { self.memory.set(ram::wSpriteStateData1 + 9, facing); } + /// `DisplayTextIDInit`'s `TextBoxBorder` at screen (0, 12)-(19, 17), the box every text id + /// but the start menu's is drawn in; or the map's own tiles again once it is gone. + fn dialogue_box(&mut self, drawn: bool) { + use super::state::poke::frame; + let at = |x: u16, y: u16| ram::wTileMap + y * 20 + x; + for y in 12..=17u16 { + for x in 0..20u16 { + let tile = if !drawn { + 0 + } else { + match (x, y) { + (0, 12) => frame::TOP_LEFT, + (19, 12) => frame::TOP_RIGHT, + (0, 17) => frame::BOTTOM_LEFT, + (19, 17) => frame::BOTTOM_RIGHT, + (_, 12) | (_, 17) => frame::HORIZONTAL, + (0, _) | (19, _) => frame::VERTICAL, + _ => 0x7f, + } + }; + self.memory.set(at(x, y), tile); + } + } + } + + /// The box opening: the font bit, the border, and -- `delay` samples later, the way the + /// cartridge loads the font's tiles first -- `DisplayTextID`'s argument. + fn open_box(&mut self, argument: u8, delay: usize) -> Vec { + self.memory.set(ram::wFontLoaded, 1); + self.dialogue_box(true); + let mut events = Vec::new(); + for _ in 0..delay { + events.extend(self.sample()); + } + self.memory.set(ram::wSpriteIndex, argument); + events.extend(self.sample()); + events + } + + fn close_box(&mut self) -> Vec { + self.memory.set(ram::wFontLoaded, 0); + self.dialogue_box(false); + self.sample() + } + /// One conversation as the cartridge draws it: a sample with the box closed (the frame the A /// press was read on), `DisplayTextIDInit` setting the font bit and `DisplayTextID` copying /// `argument` into `wSpriteIndex`, a few frames of text, and `CloseTextDisplay`. fn talk(&mut self, argument: u8) -> Vec { let mut events = self.sample(); - self.memory.set(ram::wFontLoaded, 1); - self.memory.set(ram::wSpriteIndex, argument); - events.extend(self.sample()); - events.extend(self.sample()); - self.memory.set(ram::wFontLoaded, 0); + events.extend(self.open_box(argument, 20)); events.extend(self.sample()); + events.extend(self.close_box()); events } @@ -1462,12 +1504,9 @@ fn a_conversation_the_fly_opens_indoors_pays_once_when_its_box_closes() { // Nothing on the frames the box is open: the payout waits for it to close. let mut events = f.sample(); - f.memory.set(ram::wFontLoaded, 1); - f.memory.set(ram::wSpriteIndex, 1); - events.extend(f.sample()); + events.extend(f.open_box(1, 20)); assert_eq!(count_of_kind(&events, kind::TALK), 0, "not while the box is open"); - f.memory.set(ram::wFontLoaded, 0); - let events = f.sample(); + let events = f.close_box(); assert_eq!(kinds(&events), ["talk"]); assert_eq!(events[0].value, 0.10); assert_eq!(events[0].stimulation_ms, 100); @@ -1563,14 +1602,30 @@ fn text_the_fly_did_not_open_pays_nothing() { // the frames after it are the fly's, but the box did not open on one of them. f.memory.set(ram::wJoyIgnore, 0xff); f.sample(); - f.memory.set(ram::wFontLoaded, 1); - f.memory.set(ram::wSpriteIndex, 1); - f.sample(); + f.open_box(1, 0); f.memory.set(ram::wJoyIgnore, 0); f.sample(); f.sample(); + assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "no open edge after a ready frame"); + + // START in front of someone: the menu's box is not the dialogue box, and `wSpriteIndex` + // still names the last person spoken to for the frames before the menu's own id arrives. + f.memory.set(ram::wSpriteIndex, 1); + f.sample(); + f.memory.set(ram::wFontLoaded, 1); + for _ in 0..20 { + f.sample(); + } + f.memory.set(ram::wSpriteIndex, 0); + f.sample(); f.memory.set(ram::wFontLoaded, 0); - assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0, "no open edge after a ready frame"); + assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0, "the start menu is not a conversation"); + + // The argument never arrives inside the window: nothing is guessed. + f.memory.set(ram::wSpriteIndex, 0); + f.sample(); + f.open_box(1, 60); + assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "a second of frames, then no more"); // None of that was recorded: the person is still worth one conversation. assert_eq!(kinds(&f.talk_events(1)), ["talk"]); @@ -1584,22 +1639,17 @@ fn a_conversation_that_ends_somewhere_else_pays_nothing() { f.sprite(1, 5, 4, (0, 0)); f.face(FACING_UP); f.sample(); - f.memory.set(ram::wFontLoaded, 1); - f.memory.set(ram::wSpriteIndex, 1); - f.sample(); + f.open_box(1, 20); // The script warped the fly out while the box was up. f.on_map(maps::PEWTER_CITY, 0); - f.memory.set(ram::wFontLoaded, 0); - assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0); + assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0); // A rollback mid-conversation, likewise: the watch is transient. f.on_map(maps::PEWTER_GYM, GYM); f.sample(); - f.memory.set(ram::wFontLoaded, 1); - f.sample(); + f.open_box(1, 20); f.reward.clear_transient(); - f.memory.set(ram::wFontLoaded, 0); - assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0); + assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0); } #[test] @@ -1820,3 +1870,27 @@ fn a_v6_state_restores_under_v7_with_empty_talk_and_seeded_item_ledgers() { f.face(FACING_UP); assert_eq!(kinds(&f.talk_events(1)), ["talk"]); } + +#[test] +fn a_warp_is_classified_by_the_header_still_loaded_while_the_map_id_has_moved_on() { + // `WarpFound2` writes the destination into `wCurMap` and then plays the map-change sound for + // thirty-odd frames before `EnterMap` loads the new header (`tests/rom_engage.rs` measured + // it at Route 2's gate door). On those frames the tileset and the warp table are still the + // map the fly is leaving, and the exit it is standing on is that map's. + let mut f = Fixture::booted(); + f.on_map(maps::ROUTE_2, 0); + f.warps(&[(3, 11)]); + f.visit(3, 10); + // Town -> building: the id is the gate's, the header is still Route 2's, and Route 2's + // door is an outdoor exit, so its on-exit half pays as it always did. + f.memory.set(ram::wCurMap, 0x2f); + assert_eq!(boundary_values(&f.visit(3, 11)), [0.10]); + + // Building -> town: the id is Pewter's, the header still the museum's, whose door is an + // indoor exit and pays nothing. + f.on_map(0x34, 10); + f.warps(&[(10, 7)]); + f.visit(10, 6); + f.memory.set(ram::wCurMap, maps::PEWTER_CITY); + assert!(boundary_values(&f.visit(10, 7)).is_empty()); +} diff --git a/services/flysim/crates/flysim/tests/rom_engage.rs b/services/flysim/crates/flysim/tests/rom_engage.rs new file mode 100644 index 0000000..4221a6e --- /dev/null +++ b/services/flysim/crates/flysim/tests/rom_engage.rs @@ -0,0 +1,432 @@ +//! The engagement rewards against the real cartridge: a conversation indoors and an item ball. +//! +//! Gated on `FLY_ROM` *and* on a checkpoint, the way every ROM test in this workspace is, and +//! skips cleanly without either: +//! +//! ```sh +//! FLY_ROM="$HOME/roms/pokemon-red.gb" \ +//! FLY_ENGAGE_CHECKPOINT=.local/checkpoints/ \ +//! cargo test --release -p flysim --test rom_engage -- --nocapture +//! ``` +//! +//! ## What only the cartridge can answer +//! +//! The synthetic traces in `pokemon_red/tests.rs` write `wFontLoaded`, `wSpriteIndex`, +//! `wToggleableObjectFlags` and the rest from the disassembly. They cannot say that an A press at +//! a person on this cartridge opens the box on a frame whose previous sample was the fly's own, +//! that `wSpriteIndex` names that person by the time the adapter samples, or that `PickUpItem` +//! raises the ball's bit on a frame the adapter sees. This test does, with the shipping adapter +//! sampling once a frame, and it restores the checkpoint's own `v6` reward ledger -- so it is also +//! the `v6` -> `v7` migration on real game state: the item keys are seeded from the cartridge's +//! bits and nothing already taken pays. +//! +//! ## How the fly is moved +//! +//! Not by the macro layer, and not by a brain: a small scripted walker with a breadth-first route +//! over the whole-map grid (`state::map_grid`), pressing one direction at a time, answering text +//! with B and battles with A. The question is what the adapter reads, not whether anything finds +//! its way there. From the Pewter checkpoint it walks south out of the city, down Route 2 into the +//! Viridian Forest north gate -- a building, where it talks to the old man twice -- and on into +//! the forest to the Antidote ball at (25, 11), which it picks up, and then, after rolling the +//! emulator back to before the pickup, picks up again. + +use std::collections::VecDeque; + +use flybrain_gb::adapter::RewardEvent; +use flybrain_gb::pokemon_red::macros::state::{Facing, Walkable}; +use flybrain_gb::pokemon_red::symbols::ram; +use flybrain_gb::pokemon_red::{PokemonRedReward, catalog, engage, state}; +use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons}; + +const MS_PER_FRAME: f64 = 1000.0 / 59.7275; +const PEWTER_CITY: u8 = 0x02; +const ROUTE_2: u8 = 0x0d; +const NORTH_GATE: u8 = 0x2f; +const VIRIDIAN_FOREST: u8 = 0x33; +/// `constants/item_constants.asm`: `ANTIDOTE` is `$0b`. +const ANTIDOTE: &str = "FOUND ITEM #11"; + +fn rom() -> Option> { + let path = std::env::var_os("FLY_ROM")?; + match std::fs::read(&path) { + Ok(bytes) => Some(bytes), + Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"), + } +} + +fn checkpoint() -> Option { + let path = std::env::var_os("FLY_ENGAGE_CHECKPOINT")?; + Some( + flysim::store::load(std::path::Path::new(&path)) + .expect("the checkpoint should be a FLYSIM01 envelope"), + ) +} + +struct Run { + gb: Emulator, + adapter: PokemonRedReward, + ms: f64, + frames: u64, + payouts: Vec, + /// `(wCurMap, wCurMapTileset)` on the frame each payout in `payouts` was made. + payout_maps: Vec<(u8, u8)>, +} + +fn delta(facing: Facing) -> (i16, i16) { + facing.delta() +} + +fn mask(facing: Facing) -> u8 { + match facing { + Facing::Up => buttons::UP, + Facing::Down => buttons::DOWN, + Facing::Left => buttons::LEFT, + Facing::Right => buttons::RIGHT, + } +} + +const FACINGS: [Facing; 4] = [Facing::Up, Facing::Down, Facing::Left, Facing::Right]; + +impl Run { + fn resume(rom: &[u8], checkpoint: &flysim::store::Checkpoint) -> Self { + let mut gb = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES) + .expect("binjgb should accept the cartridge"); + gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state"); + let mut adapter = PokemonRedReward::new(); + // The checkpoint was written by `pokered-unique8-v6`: this is the migration. + adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a v7 ledger"); + Self { gb, adapter, ms: 0.0, frames: 0, payouts: Vec::new(), payout_maps: Vec::new() } + } + + fn byte(&mut self, address: u16) -> u8 { + self.gb.read_wram(address) + } + + fn frame(&mut self, mask: u8) { + self.gb.set_buttons(mask); + self.gb.run_frame().expect("a frame should complete"); + self.ms += MS_PER_FRAME; + self.frames += 1; + let ms = self.ms; + let events = self.adapter.sample(&mut self.gb, ms); + let here = (self.byte(ram::wCurMap), self.byte(ram::wCurMapTileset)); + self.payout_maps.extend(events.iter().map(|_| here)); + self.payouts.extend(events); + assert!(self.frames < 200_000, "the walker is lost: {}", self.whereabouts()); + } + + fn whereabouts(&mut self) -> String { + format!( + "map {:#04x} at ({}, {}), battle {}, font {}", + self.byte(ram::wCurMap), + self.byte(ram::wXCoord), + self.byte(ram::wYCoord), + self.byte(ram::wIsInBattle), + self.byte(ram::wFontLoaded) + ) + } + + fn map(&mut self) -> u8 { + self.byte(ram::wCurMap) + } + + fn at(&mut self) -> (u8, u8) { + (self.byte(ram::wXCoord), self.byte(ram::wYCoord)) + } + + /// Whatever is on screen that is not the fly's to walk through: a battle (A through it) or a + /// text box (B through it). Returns once the overworld is controllable again. + fn settle(&mut self) { + for tick in 0..20_000u32 { + let in_battle = self.byte(ram::wIsInBattle) != 0; + let open = self.byte(ram::wFontLoaded) & 1 != 0; + if !in_battle && !open && state::controllable(&mut self.gb) { + if self.byte(ram::wWalkCounter) == 0 { + return; + } + self.frame(buttons::NONE); + continue; + } + let press = if in_battle { buttons::A } else { buttons::B }; + self.frame(if tick % 8 < 3 { press } else { buttons::NONE }); + } + panic!("the screen never settled: {}", self.whereabouts()); + } + + /// Press `facing` until the player has moved one tile, the map has changed, or it is plain + /// that the step is refused (which turns the player to face that way). + fn step(&mut self, facing: Facing) { + let (map, from) = (self.map(), self.at()); + for _ in 0..48 { + self.frame(mask(facing)); + if self.map() != map || self.at() != from { + break; + } + if self.byte(ram::wIsInBattle) != 0 || self.byte(ram::wFontLoaded) & 1 != 0 { + break; + } + } + self.frame(buttons::NONE); + self.settle(); + } + + /// Breadth-first over the decoded map, people excluded, from where the player stands to the + /// nearest tile `goal` accepts; the first step of that route, or `None`. + fn route(&mut self, goal: &dyn Fn(u8, u8) -> bool) -> Option { + let grid = state::map_grid(&mut self.gb).ok()?; + let people: Vec<(u8, u8)> = + state::npcs(&mut self.gb).iter().map(|npc| (npc.x, npc.y)).collect(); + let (width, height) = (grid.width(), grid.height()); + let start = self.at(); + let mut first: Vec> = vec![None; usize::from(width) * usize::from(height)]; + let mut seen = vec![false; first.len()]; + let index = |x: u8, y: u8| usize::from(y) * usize::from(width) + usize::from(x); + let mut queue = VecDeque::from([start]); + seen[index(start.0, start.1)] = true; + while let Some((x, y)) = queue.pop_front() { + if (x, y) != start && goal(x, y) { + return first[index(x, y)]; + } + for facing in FACINGS { + if grid.walled(x, y, facing) { + continue; + } + let (dx, dy) = delta(facing); + let (Ok(nx), Ok(ny)) = + (u8::try_from(i16::from(x) + dx), u8::try_from(i16::from(y) + dy)) + else { + continue; + }; + if nx >= width || ny >= height || seen[index(nx, ny)] { + continue; + } + if grid.walkable(nx, ny) != Walkable::Yes || people.contains(&(nx, ny)) { + continue; + } + seen[index(nx, ny)] = true; + first[index(nx, ny)] = if (x, y) == start { Some(facing) } else { first[index(x, y)] }; + queue.push_back((nx, ny)); + } + } + None + } + + /// Walk until standing on a tile `goal` accepts, on this map. + fn walk_to(&mut self, goal: &dyn Fn(u8, u8) -> bool) { + let map = self.map(); + for _ in 0..400 { + self.settle(); + assert_eq!(self.map(), map, "the walk left the map: {}", self.whereabouts()); + let (x, y) = self.at(); + if goal(x, y) { + return; + } + let Some(facing) = self.route(goal) else { + // A grid refused on this frame, or a person in the way: let a frame go by. + self.frame(buttons::NONE); + continue; + }; + self.step(facing); + } + let grid = state::map_grid(&mut self.gb); + let detail = match &grid { + Ok(grid) => { + let (x, y) = self.at(); + let mut rows = String::new(); + for ty in 0..grid.height().min(24) { + for tx in 0..grid.width() { + rows.push(if (tx, ty) == (x, y) { + '@' + } else { + match grid.walkable(tx, ty) { + Walkable::Yes => '.', + Walkable::No => '#', + Walkable::Unknown => '?', + } + }); + } + rows.push('\n'); + } + format!("reachable {}\n{rows}", grid.reachable_from(x, y)) + } + Err(refusal) => format!("grid refused: {}", refusal.label()), + }; + panic!("never reached the goal: {}; {detail}", self.whereabouts()); + } + + /// Walk to `(x, y)` and keep pressing `out` until the map changes. + fn leave_by(&mut self, x: u8, y: u8, out: Facing) { + let map = self.map(); + self.walk_to(&|tx, ty| (tx, ty) == (x, y)); + for _ in 0..8 { + self.step(out); + if self.map() != map { + // The warp's fade and the new map's first frames. + for _ in 0..60 { + self.frame(buttons::NONE); + } + self.settle(); + return; + } + } + panic!("pressing {out:?} at ({x}, {y}) never left the map: {}", self.whereabouts()); + } + + /// Stand beside `(x, y)`, face it, press A once and let the conversation run to its end. + /// Returns the payouts the conversation produced. + fn press_a_at(&mut self, x: u8, y: u8) -> Vec { + self.walk_to(&|tx, ty| tx.abs_diff(x) + ty.abs_diff(y) == 1); + let (px, py) = self.at(); + let facing = FACINGS + .into_iter() + .find(|facing| { + let (dx, dy) = delta(*facing); + i16::from(px) + dx == i16::from(x) && i16::from(py) + dy == i16::from(y) + }) + .expect("a neighbour faces the target one way"); + // Turn in place: the step is refused because the thing is in the way. + self.step(facing); + assert_eq!(self.at(), (px, py), "turning must not move the player"); + self.press_a_here() + } + + /// Press A where the player stands and faces, and let whatever it opens run to its end. + fn press_a_here(&mut self) -> Vec { + for _ in 0..4 { + self.frame(buttons::NONE); + } + let before = self.payouts.len(); + for _ in 0..6 { + self.frame(buttons::A); + } + self.frame(buttons::NONE); + self.settle(); + for _ in 0..8 { + self.frame(buttons::NONE); + } + self.payouts[before..].to_vec() + } + + fn of_kind(&self, kind: &str) -> usize { + self.payouts.iter().filter(|event| event.kind == kind).count() + } +} + +fn kinds(events: &[RewardEvent]) -> Vec<&'static str> { + events.iter().map(|event| event.kind).collect() +} + +#[test] +fn a_conversation_indoors_and_an_item_ball_each_pay_exactly_once_on_the_cartridge() { + let Some(rom) = rom() else { + eprintln!("skipped: FLY_ROM is not set"); + return; + }; + let Some(checkpoint) = checkpoint() else { + eprintln!("skipped: no FLY_ENGAGE_CHECKPOINT"); + return; + }; + let mut run = Run::resume(&rom, &checkpoint); + if run.map() != PEWTER_CITY { + eprintln!("skipped: the checkpoint is on map {:#04x}, not Pewter City", run.map()); + return; + } + let before = run.adapter.progress().counts; + + // The restore's first sample: the v6 ledger holds no item keys, so this is the seed. + run.settle(); + assert!(run.payouts.is_empty(), "the migration pays nothing: {:?}", run.payouts); + + // Pewter City's south edge, onto Route 2. + let height = run.byte(ram::wCurMapHeight) * 2; + run.walk_to(&|_, y| y == height - 1); + let (x, y) = run.at(); + run.leave_by(x, y, Facing::Down); + assert_eq!(run.map(), ROUTE_2, "{}", run.whereabouts()); + + // Route 2's door into the forest's north gate, `warp_event 3, 11`: the gate is south of + // the city, so its door is entered heading south, from the tile above it. + run.leave_by(3, 10, Facing::Down); + assert_eq!(run.map(), NORTH_GATE, "{}", run.whereabouts()); + let tileset = run.byte(ram::wCurMapTileset); + assert!(engage::indoor(tileset), "the gate is a building (tileset {tileset})"); + + // The old man at (2, 5): one conversation, one payout, when the box closes. + let first = run.press_a_at(2, 5); + eprintln!("gate, first conversation: {first:?}"); + let talks: Vec<&RewardEvent> = + first.iter().filter(|event| event.kind == catalog::kind::TALK).collect(); + assert_eq!(talks.len(), 1, "one conversation, one payout: {first:?}"); + assert_eq!(talks[0].value, 0.10); + assert!(talks[0].label.starts_with("TALKED TO #"), "{}", talks[0].label); + // The same man again, and again: not a farm. + for _ in 0..2 { + let again = run.press_a_at(2, 5); + assert!( + !again.iter().any(|event| event.kind == catalog::kind::TALK), + "a second conversation with the same person pays nothing: {again:?}" + ); + } + + // On through the gate, `warp_event 4, 7`, into the forest. + run.leave_by(4, 7, Facing::Down); + assert_eq!(run.map(), VIRIDIAN_FOREST, "{}", run.whereabouts()); + assert!(!engage::indoor(run.byte(ram::wCurMapTileset)), "the forest is not a building"); + + // The Antidote ball at (25, 11). Save the game just before, to take it twice. + run.walk_to(&|tx, ty| tx.abs_diff(25) + ty.abs_diff(11) == 1); + let slot = run.gb.export_state().expect("an emulator state"); + let first = run.press_a_at(25, 11); + eprintln!("forest, the ball: {first:?}"); + let items: Vec<&RewardEvent> = + first.iter().filter(|event| event.kind == catalog::kind::ITEM).collect(); + assert_eq!(items.len(), 1, "one pickup, one payout: {first:?}"); + assert_eq!(items[0].value, 0.15); + assert_eq!(items[0].label, ANTIDOTE); + assert!( + !first.iter().any(|event| event.kind == catalog::kind::TALK), + "a ball is not a conversation, and the forest is not indoors" + ); + // The ball is gone: pressing A at the empty tile pays nothing. + let empty = run.press_a_here(); + assert!(!empty.iter().any(|event| event.kind == catalog::kind::ITEM), "{empty:?}"); + + // A rollback to the slot with the ball still there, the way the ratchet restores one, and + // the fly takes it again: the same item, and it does not pay twice. + run.gb.import_state(&slot).expect("the slot restores"); + run.adapter.clear_transient(); + run.frame(buttons::NONE); + run.settle(); + let again = run.press_a_at(25, 11); + eprintln!("forest, the ball after a rollback: {again:?}"); + assert!( + !again.iter().any(|event| event.kind == catalog::kind::ITEM), + "once per item for the run: {again:?}" + ); + + let after = run.adapter.progress().counts; + eprintln!( + "{:.1} brain minutes; talk {} -> {}, item {} -> {}; every payout (kind, (map, tileset)): {:?}", + run.ms / 60_000.0, + before[catalog::kind::TALK], + after[catalog::kind::TALK], + before[catalog::kind::ITEM], + after[catalog::kind::ITEM], + kinds(&run.payouts).iter().zip(&run.payout_maps).collect::>() + ); + // The gate's exits were stood beside and walked through, and none of them paid: an indoor + // exit pays nothing. What *does* show up under the gate's id is Route 2's door, the + // outdoor exit the fly took: for the thirty-odd frames of `PlayMapChangeSound` the cartridge + // has already written the new `wCurMap` while the header, the warp table and the tileset + // are still Route 2's, and the rule reads that frame as what it is -- an outdoor exit. + for (event, (map, tileset)) in run.payouts.iter().zip(&run.payout_maps) { + assert!( + !(event.kind == catalog::kind::BOUNDARY && engage::indoor(*tileset)), + "a boundary payout on an indoor map's own header (map {map}): {event:?}" + ); + } + assert_eq!(run.of_kind(catalog::kind::TALK), 1); + assert_eq!(run.of_kind(catalog::kind::ITEM), 1); + assert_eq!(after[catalog::kind::TALK], before[catalog::kind::TALK] + 1); + assert_eq!(after[catalog::kind::ITEM], before[catalog::kind::ITEM] + 1); +}