//! Unit tests for the palette table, the pathfinder and every script's control flow. //! //! Everything here runs against a fake game, for the same reason the adapter's tests run against //! synthetic WRAM traces: a test that needs the cartridge cannot say *why* it failed. The //! cartridge-gated half — that `GO EXIT` really does leave Red's house, that `NEXT` really does //! advance a text box — is in `tests/rom_macros.rs`. //! //! [`World`] is not a Game Boy, but the three facts about walking that decide whether `GO EXIT` //! works are the measured ones from `docs/design/room-escape.md` section 3: a step takes a fixed //! number of frames of held direction, a doormat on the bottom row fires when it is stepped //! *down* onto or stepped down *off*, and a sideways step onto one does nothing. It implements //! agent A's seam and nothing more, so a script that the seam cannot support fails here the way //! it would on the cartridge. use std::collections::{BTreeSet, VecDeque}; use crate::adapter::PlaceKind; use crate::emulator::buttons; use super::cartridge::{ 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; use super::executor::{ FRAME_CAP, FRAMES_PER_PLANNED_TILE, MacroAbort, MacroMachine, MacroRefused, Refusal, WALK_FRAME_CEILING, walk_budget, }; use super::palette::{ 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, }; use super::path::{self, Exit, Way}; use super::super::maps; use super::plan; use super::state::{ Battle, BattleKind, BattleMenu, Connections, Cursor, EnemyMon, Facing, GameState, MapSize, Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu, Status, TextBox, Walkable, Warp, }; /// `SPRITE_POKE_BALL`, the first still sprite: the picture id a fake gives an object rather than a /// person (`constants/sprite_constants.asm`). const BALL_SPRITE: u8 = 0x3d; /// Which list is accepting input, and therefore what the shared cursor means. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum List { /// Nothing: an animation, a message, or a screen the seam reports no cursor for. None, /// FIGHT / PKMN / ITEM / RUN. BattleMain, /// The move list, with this many moves. Moves(u8), /// The party list. BattleParty, /// The start menu. Start, /// A mart, on this screen. Shop(ShopScreen), /// A PC. Pc, /// The bag inside a battle, which agent A's seam does not report a cursor for. BattleBag, } /// What an A press in a menu opens next. #[derive(Debug, Clone, Copy)] struct Opens { list: List, cursor: u8, max: u8, grid: bool, } /// A fake game: agent A's seam over plain fields, plus the button behaviour that matters. struct World { scene: Scene, map: u8, size: MapSize, player: Tile, facing: Facing, /// Tiles that are not walkable; everything else inside `size` is. walls: BTreeSet, /// Tiles the predicate cannot answer for, which is agent A's windowed `Unknown`. unseen: BTreeSet, 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, cursor: u8, cursor_max: u8, /// Red's two-by-two menu geometry rather than a plain column. grid: bool, battle: Option<(BattleKind, bool, bool)>, mons: Vec, active: Option, /// The Pokémon on the other side, when a test is about which one it is. /// /// `None` is the seam answering nothing, which is what most of these fixtures want: the /// species only matters to `THROW BALL`'s precondition (section 12.9). enemy: Option, money: u32, bag: Vec<(u8, u8)>, stock: Vec, /// Tiles the game lets the player talk *over*: a mart's or a centre's counter. counters: BTreeSet, /// Errands this run has discharged (`docs/design/macros.md` section 13). areas: BTreeSet<(Amenity, u8)>, /// Tiles the cartridge pushes the fly off (`infra/docs/macros-traps.md` row 37). pushes: BTreeSet, /// Maps a `GO FRONTIER` has proved it cannot reach the frontier of (section 12.14). /// /// Written by [`drive`] from the machine, exactly as `PokemonPalette` writes it in the sim /// loop, so a test sees the ledger the next decision would see. exhausted: BTreeSet, visited: BTreeSet, /// Tiles of this map the run has stood on, for `GO FRONTIER` and the plan's "untalked" test. stood: BTreeSet, /// Maps the run has been on, for `GO ROUTE`'s unvisited interior. seen_maps: BTreeSet, /// Where the ladder's next rung is, for `GO OBJECTIVE`. objective: Option, /// What this session has talked to, for `GO NPC`, `GO ITEM` and `TALK`. talked: BTreeSet, /// The session's blocked- and reached-target ledgers, which [`run`] writes for the machine /// exactly as `PokemonPalette` does in the sim loop (`docs/design/macros.md` section 12). targets: Targets, /// Steps this world refuses: a ledge, a tile-pair collision, somebody in the way. /// /// Directed, because that is what the cartridge encodes ([`path::Refusal`]): the entry /// `((3, 4), Up)` refuses a step north out of (3, 4) and says nothing about coming back down. refuse: Vec<(Tile, Facing)>, /// Agent A's ten-by-nine window, when a test wants the real predicate's blind spot. /// /// `pokemon_red::state::walkable` can only answer for `x - 4 ..= x + 5` and `y - 4 ..= y + 4`, /// and the window moves with the player: every farther tile reads [`Walkable::Unknown`]. A /// fake with a static `unseen` set cannot show what that does to a search that re-plans every /// tile, which is the whole of the 2026-09-17 stall. window: bool, /// Frames of continuous held direction one tile takes. tile_frames: u32, /// A world where the player never moves, for the three-failure rule. glue: bool, /// B presses still needed to back out of a menu. b_to_close: u8, /// START presses still needed to open the start menu. start_to_open: u8, /// What the next A presses open. opens: VecDeque, /// Frames the cartridge spends *drawing* a list an A press opened, before it accepts input. /// /// Zero everywhere but the one test this is for. On the cartridge it is not zero and it is not /// bounded by the twenty frames a script's `settle` waits: measured 2026-09-22, `THROW BALL` /// pressed ITEM, settled, and then read the battle *menu*'s cursor because the bag had not /// drawn yet -- 63 starts, 63 `blocked` (section 12.11). opens_draw_in: u32, /// The list an A press opened and the frame it starts accepting input on. pending: Option<(u32, Opens)>, /// A scene the world switches to at this frame, for the abort rule. switch: Option<(u32, Scene)>, /// 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 a text box is drawn on a scene that is not [`Scene::Dialog`] /// ([`MacroState::text_open`]). /// /// `Dialog` *is* an open box, so the reading is true there by construction; the field is for /// `Unknown`, which holds both a screen with words on it -- the Pokedex, the trainer card, /// OPTION -- and a frame of the overworld the cartridge is driving (section 12.13). box_open: bool, /// 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. scripted_at: Option, /// Every completed pulse, in order. pulses: Vec, held: u32, previous: u8, frames: u32, } fn mon(slot: u8, hp: u16, max_hp: u16, moves: &[(u8, u8)]) -> Mon { let mut slots = [None, None, None, None]; for (index, (id, pp)) in moves.iter().enumerate() { slots[index] = Some(Move { id: *id, pp: *pp, pp_up: 0 }); } Mon { slot, species: slot + 1, level: 5, hp, max_hp, status: Status::Healthy, moves: slots, } } impl World { /// An empty eight-by-eight room with no exits and nothing in it. fn room() -> Self { Self { scene: Scene::Overworld, map: 0x25, size: MapSize { width: 8, height: 8 }, player: Tile::new(3, 3), facing: Facing::Down, walls: BTreeSet::new(), unseen: BTreeSet::new(), warps: Vec::new(), connections: Connections::default(), npcs: Vec::new(), offscreen: Vec::new(), signs: Vec::new(), list: List::None, cursor: 0, cursor_max: 0, grid: false, battle: None, enemy: None, mons: vec![mon(0, 20, 20, &[(33, 30)])], active: None, money: 0, bag: Vec::new(), counters: BTreeSet::new(), areas: BTreeSet::new(), pushes: BTreeSet::new(), exhausted: BTreeSet::new(), stock: Vec::new(), visited: BTreeSet::new(), stood: BTreeSet::new(), seen_maps: BTreeSet::new(), objective: None, talked: BTreeSet::new(), targets: Targets::default(), refuse: Vec::new(), window: false, tile_frames: 16, glue: false, b_to_close: 1, start_to_open: 1, opens: VecDeque::new(), opens_draw_in: 0, pending: None, switch: None, heal_at: None, box_open: false, prompt: false, scripted: false, scripted_at: None, pulses: Vec::new(), held: 0, previous: buttons::NONE, frames: 0, } } /// Red's ground floor as the survey in `docs/design/room-escape.md` measured it: two doormats /// side by side on the bottom row and a staircase inside the room. fn ground_floor() -> Self { let mut world = Self::room(); world.warps = vec![ Warp { x: 7, y: 1, destination_warp: 2, destination_map: 0x26 }, Warp { x: 2, y: 7, destination_warp: 1, destination_map: 0x00 }, Warp { x: 3, y: 7, destination_warp: 1, destination_map: 0x00 }, ]; world } /// A wild battle on the player's turn: one hurt Pokémon out, one healthy on the bench, one /// fainted, and three moves of which the last is a status move. fn battle() -> Self { let mut world = Self::room(); world.scene = Scene::Battle { own_turn: true, forced_switch: false }; world.battle = Some((BattleKind::Wild, true, false)); world.list = List::BattleMain; world.cursor_max = 3; world.grid = true; world.mons = vec![ mon(0, 4, 20, &[(33, 30), (52, 20), (45, 40)]), mon(1, 18, 20, &[(33, 30)]), mon(2, 0, 20, &[(33, 30)]), ]; world.active = Some(0); world } /// Viridian City's mart, as `data/maps/objects/ViridianMart.asm` declares it. /// /// Four blocks by four, so eight tiles by eight; two doormats on the bottom row at (3, 7) and /// (4, 7); `object_event 0, 5, SPRITE_CLERK` behind a counter that runs down column 1. The /// counter and the floor behind it are walls, which is the fact that matters: **none of the /// four tiles around the clerk can be stood on**, so an approach that only knew how to stand /// beside somebody could never reach the counter at all. fn mart() -> Self { let mut world = Self::room(); world.map = maps::VIRIDIAN_MART; world.player = Tile::new(3, 6); world.warps = vec![ Warp { x: 3, y: 7, destination_warp: 1, destination_map: 0xff }, Warp { x: 4, y: 7, destination_warp: 1, destination_map: 0xff }, ]; world.npcs = vec![Npc { slot: 1, picture: poke_sprite::CLERK, x: 0, y: 5, facing: Facing::Right, }]; world.counters = BTreeSet::from([Tile::new(1, 5)]); world.walls = BTreeSet::from([ Tile::new(0, 4), Tile::new(0, 5), Tile::new(0, 6), Tile::new(1, 5), ]); world.money = 3_000; world } /// Viridian City's Pokémon Center, as `data/maps/objects/ViridianPokecenter.asm` declares it. /// /// Seven blocks by four, so fourteen tiles by eight; `object_event 3, 1, SPRITE_NURSE` behind /// the counter tile at (3, 2), with the wall above her and the floor either side of her not /// standable. The only way to talk to her is from (3, 3), facing up, over the counter. fn center() -> Self { let mut world = Self::room(); world.map = maps::VIRIDIAN_POKECENTER; world.size = MapSize { width: 14, height: 8 }; world.player = Tile::new(3, 6); world.warps = vec![ Warp { x: 3, y: 7, destination_warp: 1, destination_map: 0xff }, Warp { x: 4, y: 7, destination_warp: 1, destination_map: 0xff }, ]; world.npcs = vec![Npc { slot: 1, picture: poke_sprite::NURSE, x: 3, y: 1, facing: Facing::Down, }]; world.counters = BTreeSet::from([Tile::new(3, 2)]); world.walls = BTreeSet::from([ Tile::new(3, 0), Tile::new(2, 1), Tile::new(4, 1), Tile::new(3, 2), ]); 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 } fn wall(mut self, tiles: &[(u8, u8)]) -> Self { for (x, y) in tiles { self.walls.insert(Tile::new(*x, *y)); } self } fn frame(&mut self, mask: u8) { self.frames += 1; if let Some((at, next)) = self.pending && self.frames >= at { self.list = next.list; self.cursor = next.cursor; self.cursor_max = next.max; self.grid = next.grid; self.pending = None; } if let Some(at) = self.scripted_at && self.frames >= at { self.scripted = true; } if let Some((at, scene)) = self.switch && self.frames >= at { self.scene = scene; self.switch = None; } if let Some(at) = self.heal_at && self.frames >= at { for mon in &mut self.mons { mon.hp = mon.max_hp; mon.status = Status::Healthy; } self.heal_at = None; } if mask == buttons::NONE && self.previous != buttons::NONE { self.pulses.push(self.previous); self.on_pulse(self.previous); } if self.scene == Scene::Overworld { match direction_of(mask) { Some(facing) => { if mask == self.previous { self.held += 1; } else { self.held = 1; self.facing = facing; } if self.held >= self.tile_frames { self.held = 0; self.attempt(facing); } } None => self.held = 0, } } self.previous = mask; } fn on_pulse(&mut self, mask: u8) { if self.scene == Scene::Overworld { if mask == buttons::START { self.start_to_open = self.start_to_open.saturating_sub(1); if self.start_to_open == 0 { self.scene = Scene::Menu; self.list = List::Start; self.cursor_max = 5; } } return; } if let Some(facing) = direction_of(mask) { self.move_cursor(facing); } if mask == buttons::B { self.b_to_close = self.b_to_close.saturating_sub(1); if self.b_to_close == 0 { self.scene = Scene::Overworld; self.list = List::None; } } if mask == buttons::A && let Some(next) = self.opens.pop_front() { if self.opens_draw_in > 0 { self.pending = Some((self.frames + self.opens_draw_in, next)); } else { self.list = next.list; self.cursor = next.cursor; self.cursor_max = next.max; self.grid = next.grid; } } } fn move_cursor(&mut self, facing: Facing) { if self.list == List::None { return; } let here = i16::from(self.cursor); let target = if self.grid { // Red's battle menu: two **columns** of two, indexed by column. `wCurrentMenuItem` is // the row inside the column the cursor is in and selection adds two for the right // column, so the order is FIGHT, ITEM, PKMN, RUN -- up and down move one inside a // column, left and right move two across (surveyed 2026-09-22; the fake had it // row-major, which is the same mistake `battle_entry` had). match facing { Facing::Down if here % 2 == 0 => here + 1, Facing::Up if here % 2 == 1 => here - 1, Facing::Right => here + 2, Facing::Left => here - 2, _ => here, } } else { match facing { Facing::Down | Facing::Right => here + 1, Facing::Up | Facing::Left => here - 1, } }; if target >= 0 && target <= i16::from(self.cursor_max) { self.cursor = target as u8; } } /// One attempted overworld step, with the cartridge's warp behaviour. fn attempt(&mut self, facing: Facing) { if self.glue || self.refuse.contains(&(self.player, facing)) { return; } let (dx, dy) = facing.delta(); let x = i16::from(self.player.x) + dx; let y = i16::from(self.player.y) + dy; let off_map = x < 0 || y < 0 || x >= i16::from(self.size.width) || y >= i16::from(self.size.height); if off_map { // Standing on a doormat and stepping off it goes through the door; otherwise only a // connected edge leads anywhere. let mat = self .warps .iter() .find(|warp| warp.x == self.player.x && warp.y == self.player.y) .copied(); if let Some(warp) = mat { self.map = warp.destination_map; } else if self.connected(facing) { self.map = 0xfe; } return; } let next = Tile::new(x as u8, y as u8); if self.walkable(next.x, next.y) != Walkable::Yes { return; } self.player = next; let Some(warp) = self.warps.iter().find(|warp| warp.x == next.x && warp.y == next.y).copied() else { return; }; // An interior warp fires on the step onto it; a doormat only when the step onto it is the // same direction the step off the map would be. match outward(next, self.size.height) { None => self.map = warp.destination_map, Some(out) if out == facing => self.map = warp.destination_map, Some(_) => {} } } fn connected(&self, facing: Facing) -> bool { match facing { Facing::Up => self.connections.north, Facing::Down => self.connections.south, Facing::Left => self.connections.west, Facing::Right => self.connections.east, } } fn cursor_state(&self) -> Cursor { Cursor { current: self.cursor, max: self.cursor_max, top_y: 0, top_x: 0, watched_keys: 0xff, } } } impl GameState for World { fn scene(&mut self) -> Scene { self.scene } fn player(&mut self) -> Option { Some(Player { map: self.map, x: self.player.x, y: self.player.y, facing: self.facing }) } fn map_size(&mut self) -> Option { Some(self.size) } fn party(&mut self) -> Party { Party { mons: self.mons.clone(), active: self.active } } fn battle(&mut self) -> Option { let (kind, own_turn, forced_switch) = self.battle?; let menu = match self.list { List::BattleMain => BattleMenu::Main { cursor: self.cursor }, List::Moves(count) => BattleMenu::Moves { cursor: Some(self.cursor), count }, List::BattleParty => BattleMenu::Party { cursor: self.cursor }, // The bag opened from a battle's ITEM entry, which the seam reports a cursor for since // section 14: `ITEM` and `THROW BALL` both navigate it by reading. List::BattleBag => { BattleMenu::Bag { cursor: self.cursor, count: self.cursor_max.saturating_add(1) } } _ => BattleMenu::None, }; let own = self.active.and_then(|slot| self.mons.iter().find(|mon| mon.slot == slot)).copied(); Some(Battle { kind, own_turn, forced_switch, menu, own, enemy: self.enemy }) } fn text_box(&mut self) -> TextBox { let talking = matches!(self.scene, Scene::Dialog | Scene::Unknown); TextBox { open: talking, waiting: talking } } fn start_menu(&mut self) -> Option { (self.list == List::Start) .then(|| StartMenu { cursor: self.cursor_state(), items: self.cursor_max + 1 }) } fn shop(&mut self) -> Option { match self.list { List::Shop(screen) => Some(Shop { screen, cursor: self.cursor_state() }), _ => None, } } fn pc(&mut self) -> Option { (self.list == List::Pc).then(|| Pc { cursor: self.cursor_state() }) } fn money(&mut self) -> u32 { self.money } fn bag(&mut self) -> Vec { self.bag.iter().map(|(id, count)| super::state::BagItem { id: *id, count: *count }).collect() } fn npcs(&mut self) -> Vec { self.npcs.clone() } fn offscreen_npcs(&mut self) -> Vec { self.offscreen.clone() } fn signs(&mut self) -> Vec { self.signs.clone() } fn walkable(&mut self, x: u8, y: u8) -> Walkable { let tile = Tile::new(x, y); let offscreen = self.window && { let dx = i32::from(x) - i32::from(self.player.x); let dy = i32::from(y) - i32::from(self.player.y); !((-4..=5).contains(&dx) && (-4..=4).contains(&dy)) }; if x >= self.size.width || y >= self.size.height { Walkable::No } else if offscreen || self.unseen.contains(&tile) { Walkable::Unknown } else if self.walls.contains(&tile) { Walkable::No } else { Walkable::Yes } } fn warps(&mut self) -> Vec { self.warps.clone() } fn connections(&mut self) -> Connections { self.connections } } impl MacroState for World { fn scripted(&mut self) -> bool { self.scripted } /// `wFontLoaded` is set for every dialogue box, which is what `Dialog` is; on `Unknown` the /// fixture has to say, because that is the reading that tells a screen from a scripted /// overworld frame (section 12.13). fn text_open(&mut self) -> bool { self.scene == Scene::Dialog || self.box_open } fn frontier_exhausted(&mut self) -> bool { self.exhausted.contains(&self.map) } /// 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() } fn counter_tile(&mut self, x: u8, y: u8) -> bool { self.counters.contains(&Tile::new(x, y)) } fn area_visited(&mut self, kind: Amenity, area: u8) -> bool { self.areas.contains(&(kind, area)) } fn pushed_tile(&mut self, x: u8, y: u8) -> bool { self.pushes.contains(&Tile::new(x, y)) } fn exit_visited(&mut self, exit: ExitId) -> bool { self.visited.contains(&exit) } fn tile_visited(&mut self, x: u8, y: u8) -> bool { self.stood.contains(&Tile::new(x, y)) } fn map_visited(&mut self, map: u8) -> bool { self.seen_maps.contains(&map) } fn talked(&mut self, target: TalkTarget) -> bool { self.talked.contains(&target) } fn blocked(&mut self, target: TargetKey) -> bool { self.targets.blocked(self.map, target) } fn reached(&mut self, target: TargetKey) -> bool { self.targets.reached(self.map, target) } fn refused_here(&mut self, slot: u8) -> bool { self.targets.refused(self.map, slot, self.player) } fn objective(&mut self) -> Option { self.objective } } fn direction_of(mask: u8) -> Option { FACINGS.into_iter().find(|facing| button(*facing) == mask) } /// The cartridge's rule, as `docs/design/room-escape.md` section 3 measured it: a doormat is on /// the bottom row and needs a DOWN step; every other warp fires on the step onto it. fn outward(tile: Tile, height: u8) -> Option { (tile.y + 1 == height).then_some(Facing::Down) } /// Start `kind`'s slot and run to completion, returning the outcome. /// /// The slot is found by name in the palette the world's own scene produces, so a test never /// hardcodes a slot number that section 3 could move. fn run(world: &mut World, kind: MacroKind) -> Result { let mut machine = MacroMachine::new(0x1234_5678); run_with(&mut machine, world, kind) } /// [`run`], on a machine that outlives the call. /// /// The executor's session state — the routes a frame cap cut short — lives on the machine, so a /// test about resuming a walk has to press the same machine twice, exactly as the sim loop does /// (`PokemonPalette` owns one for the run). fn run_with( machine: &mut MacroMachine, world: &mut World, kind: MacroKind, ) -> Result { let (palette, slot) = pick(world, kind); drive(machine, &palette, slot, world) } /// [`run_with`], on a palette the caller dealt: start the slot, drive to the outcome, and do the /// driver's own bookkeeping after it. fn drive( machine: &mut MacroMachine, palette: &Palette, slot: MacroId, world: &mut World, ) -> Result { let kind = palette.slot(slot).expect("the caller dealt this slot").kind; if let Err(refused) = machine.start(palette, slot, world) { // The driver drains the ledgers after every `start`, refusal included: a `no route` // refusal earns blocked entries and presses nothing, so nothing else would collect them // (`PokemonPalette::start`). while let Some((map, target)) = machine.take_blocked() { world.targets.record_blocked(map, target); } if let Some(map) = machine.take_exhausted() { world.exhausted.insert(map); } if let Some((map, slot, tile)) = machine.take_refused() { world.targets.record_refused(map, slot, tile); } return Err(refused); } // A walk's cap is its plan's, so the bound here is the ceiling on any macro plus slack. for _ in 0..(WALK_FRAME_CEILING + 64) { match machine.step(world) { Some(mask) => world.frame(mask), None => break, } } assert!(machine.running().is_none(), "{} never gave the buttons back", kind.name()); // The driver hands the machine every frame, not only the ones a macro owns, because whether a // conversation ended cleanly is a question about the frames after `TALK` gave the buttons back // (`PokemonPalette::observe`, `docs/design/macros.md` section 12.4). One frame is enough here: // the fake's scene does not linger. machine.observe_frame(world); // 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); } if let Some(map) = machine.take_exhausted() { world.exhausted.insert(map); } if let Some((map, target, closer)) = machine.take_timeout() { world.targets.record_timeout(map, target, closer); } 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); } } /// 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. /// /// `MENU` is the only one (section 12.11): it is still a type, a population, a tag and a script -- /// the roles and `--print-compatibility` depend on the type list -- and it is on no pad, so /// [`pick`] cannot find it. Its script is exercised here rather than deleted, because what took it /// off the pad is that the start menu has nothing in it for the fly and not that pressing START is /// wrong. fn forced(kind: MacroKind, world: &mut World) -> (Palette, MacroId) { let mut slots = [None; super::palette::SLOTS]; slots[usize::from(kind.slot())] = Some(super::palette::MacroSpec::of(kind)); (Palette { scene: world.scene(), slots }, MacroId(kind.slot())) } fn pick(world: &mut World, kind: MacroKind) -> (Palette, MacroId) { let scene = world.scene(); // The shipping mode is `PaletteMode::Plan` (`MacroMode::Macros` in `flysim::snapshot`), which // deals the *set* section 12 asks for; `Palette::for_scene` is the older six fixed channels and // does not carry `GO OBJECTIVE` at all. Tests ask the channel palette first, because most of // them are about a macro that is on both, and fall back to the plan's — which is what // `on_the_pad` has always asked. let palette = Palette::for_scene(scene, world); if let Some(slot) = palette.slots.iter().position(|slot| slot.is_some_and(|spec| spec.kind == kind)) { return (palette, MacroId(slot as u8)); } let palette = plan::plan_for(scene, world); let slot = palette .slots .iter() .position(|slot| slot.is_some_and(|spec| spec.kind == kind)) .unwrap_or_else(|| panic!("{} is not on the {} pad", kind.name(), scene.label())); (palette, MacroId(slot as u8)) } /// The macros a palette binds, in type order, which is also slot order (section 14). /// /// The bound ones only: with a slot per type an unbound cell is a hole in the array rather than a /// place in a row of six, so listing the holes would be listing the twenty-odd types this scene /// simply does not have. fn names(palette: &Palette) -> Vec<&'static str> { palette.slots.iter().flatten().map(|spec| spec.name).collect() } // --------------------------------------------------------------------------------------------- // Section 3: the palette table // --------------------------------------------------------------------------------------------- #[test] fn the_title_screen_has_no_palette() { let mut world = World::room(); assert_eq!(Palette::for_scene(Scene::Title, &mut world).bound(), 0); } #[test] fn the_indoor_overworld_row_is_section_nine_ones_row() { let mut world = World::ground_floor().wall(&[(3, 4)]); // One person and one object, so the sprite list holds both kinds. `GO NPC` is on the indoor // pad too since section 14 -- the six-slot cap was the only reason it was not. world.npcs = vec![ Npc { slot: 1, picture: 1, x: 4, y: 3, facing: Facing::Down }, Npc { slot: 2, picture: BALL_SPRITE, x: 3, y: 4, facing: Facing::Down }, ]; // A second object away from the tile ahead, because the ball at (3, 4) is what the fly is // facing and a thing already faced is `TALK`'s and not `GO ITEM`'s (2026-09-17). world.signs = vec![Sign { x: 6, y: 6, text_id: 3 }]; let palette = Palette::for_scene(Scene::Overworld, &mut world); assert_eq!( names(&palette), ["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "TALK"], "and no `MENU`, which section 12.11 took off this row" ); } #[test] fn the_outdoor_overworld_row_has_the_route_and_no_passage_in_it() { // Pallet Town: no building to leave and no floor to change, so the route onward is there and // `GO OUT` and `GO WARP` are not (section 9.1). let mut world = World::ground_floor(); world.map = 0x00; world.connections.south = true; world.npcs = vec![Npc { slot: 1, picture: 1, x: 4, y: 3, facing: Facing::Down }]; let palette = Palette::for_scene(Scene::Overworld, &mut world); assert_eq!(names(&palette), ["GO ROUTE", "GO NPC", "GO FRONTIER"]); } #[test] fn an_overworld_with_no_way_out_leaves_the_leaving_buttons_unbound() { let mut world = World::room(); let palette = Palette::for_scene(Scene::Overworld, &mut world); assert_eq!(palette.slot(MacroId(MacroKind::GoOut.slot())), None, "a sealed room binds no way out"); assert_eq!(names(&palette), ["GO FRONTIER"], "and there is always ground to cover"); } #[test] fn a_warp_is_classified_by_where_it_goes() { // Section 9.1, on Red's ground floor: the staircase leads to another interior map and the two // doormats lead outdoors, and that is the whole difference between GO WARP and GO OUT. let mut world = World::ground_floor(); let exits = path::exits(&mut world); let way_of = |tile: Tile, exits: &[Exit]| { exits.iter().find(|exit| exit.tile == tile).expect("an exit there").way }; assert_eq!(way_of(Tile::new(7, 1), &exits), Way::Passage, "the staircase"); assert_eq!(way_of(Tile::new(2, 7), &exits), Way::Exit, "the left doormat"); assert_eq!(way_of(Tile::new(3, 7), &exits), Way::Exit, "the right doormat"); assert!(precondition(MacroKind::GoOut, &mut world)); assert!(precondition(MacroKind::GoWarp, &mut world)); assert!(!precondition(MacroKind::GoRoute, &mut world), "indoors there is no route"); // Outdoors every warp is a door into somewhere and every edge is the next area. let mut town = World::ground_floor(); town.map = 0x00; assert!(path::exits(&mut town).iter().all(|exit| exit.way == Way::Route)); assert!(!precondition(MacroKind::GoOut, &mut town), "outdoors there is nothing to leave"); assert!(!precondition(MacroKind::GoWarp, &mut town)); assert!(precondition(MacroKind::GoRoute, &mut town)); } #[test] fn a_connected_edge_is_a_way_out_even_with_no_warps() { let mut world = World::room(); world.connections.south = true; assert!(precondition(MacroKind::GoOut, &mut world)); } #[test] fn go_npc_is_unbound_when_nobody_is_visible() { let mut world = World::room(); assert!(!precondition(MacroKind::GoNpc, &mut world)); } #[test] fn go_npc_and_go_item_split_the_sprite_list_between_them() { // The sixteen sprite slots hold the whole `object_event` list, people and objects alike, so // "is there a sprite" would have bound both slots for either. `Npc::person` is the split. let mut balls = World::room(); balls.npcs = vec![Npc { slot: 1, picture: BALL_SPRITE, x: 4, y: 3, facing: Facing::Down }]; assert!(!precondition(MacroKind::GoNpc, &mut balls), "a Pokeball is not a person"); assert!(precondition(MacroKind::GoItem, &mut balls)); let mut person = World::room(); person.npcs = vec![Npc { slot: 1, picture: 3, x: 4, y: 3, facing: Facing::Down }]; assert!(precondition(MacroKind::GoNpc, &mut person)); assert!(!precondition(MacroKind::GoItem, &mut person), "a person is not an object"); } #[test] fn go_item_binds_on_a_sign_with_no_sprites_at_all() { // A sign is a `bg_event`, not a sprite: nothing about it reaches `npcs` or `walkable`, which // is why the old `LOOK` precondition (a wall ahead) was the only thing that ever saw one. let mut world = World::room(); assert!(!precondition(MacroKind::GoItem, &mut world), "an empty room has nothing to face"); world.signs = vec![Sign { x: 2, y: 0, text_id: 1 }]; assert!(precondition(MacroKind::GoItem, &mut world)); let palette = Palette::for_scene(Scene::Overworld, &mut world); assert!(names(&palette).contains(&"GO ITEM")); } #[test] fn the_overworld_row_binds_no_object_slot_in_an_empty_room() { let mut world = World::room(); let palette = Palette::for_scene(Scene::Overworld, &mut world); assert_eq!(palette.slots[1], None, "nobody to walk to"); assert_eq!(palette.slots[2], None, "and nothing to walk to"); assert_eq!( palette.slots[4], None, "and nothing to press A at: section 12 keeps TALK off the pad unless the tile ahead \ holds something untalked" ); } #[test] fn interactables_are_the_objects_and_the_signs_deduplicated() { let mut world = World::room(); world.npcs = vec![ Npc { slot: 1, picture: 3, x: 1, y: 1, facing: Facing::Down }, Npc { slot: 2, picture: BALL_SPRITE, x: 5, y: 2, facing: Facing::Down }, Npc { slot: 3, picture: BALL_SPRITE + 2, x: 2, y: 0, facing: Facing::Down }, ]; // One sign on a tile an object already occupies, and one of its own. world.signs = vec![Sign { x: 2, y: 0, text_id: 1 }, Sign { x: 7, y: 7, text_id: 2 }]; assert_eq!( path::interactables(&mut world), vec![Tile::new(2, 0), Tile::new(5, 2), Tile::new(7, 7)], "the person is not one, and the doubled tile is one goal" ); } #[test] fn the_dialog_row_is_next_yes_and_no() { let mut world = World::room(); let palette = Palette::for_scene(Scene::Dialog, &mut world); assert_eq!(names(&palette), ["NEXT", "YES", "NO"]); } #[test] fn an_unknown_scene_is_dialog_with_advance_only() { let mut world = World::room(); world.box_open = true; let palette = Palette::for_scene(Scene::Unknown, &mut world); // Row 9 of `infra/docs/macros-traps.md`, closed by section 13.1: B is what leaves the Pokédex, // the trainer card and OPTION, and all three read `Unknown` with a box drawn. assert_eq!(names(&palette), ["NEXT", "BACK"]); } #[test] fn an_unknown_frame_with_no_box_on_it_deals_nothing() { // Section 12.13, the rung-10 Pewter loop. The other half of `Unknown` is the overworld with // the cartridge driving -- a warp in flight, a push-back, the museum guide walking the fly in // -- where `scene::detect` falls through because the buttons are not reaching the player. // There is no box to advance and no screen to leave, so `NEXT` and `BACK` are an A and a B // pressed into somebody else's script: they change nothing and they complete where the fly // stands, which is section 12.2's trap. The pad is empty and the fly waits. let mut world = World::room(); world.scripted = true; assert!(!world.text_open()); let palette = Palette::for_scene(Scene::Unknown, &mut world); assert_eq!(palette.bound(), 0, "an A and a B into a script are not buttons"); // And the moment the cartridge draws something, both are back. world.box_open = true; assert_eq!(names(&Palette::for_scene(Scene::Unknown, &mut world)), ["NEXT", "BACK"]); } #[test] fn the_menu_row_is_close_confirm_back() { let mut world = World::room(); let palette = Palette::for_scene(Scene::Menu, &mut world); assert_eq!(names(&palette), ["CLOSE", "CONFIRM", "BACK"]); } #[test] fn the_battle_row_is_the_move_buttons_switch_item_and_never_next() { let mut world = World::battle(); world.bag = vec![(item::POTION, 1)]; let scene = world.scene(); // Section 13.1: `RUN` is bound only in a wild battle the fly is *losing*, and `World::battle` // has an eighteen-of-twenty Pokémon on the bench -- something healthier to send in, so the // fight is still worth having and the slot is empty. // // Section 12.10: **`NEXT` is gone from this row.** It was the backstop for a turn where every // other button dropped, and it was an A press on the cursor -- which sits on FIGHT, so it // opened the move list, whose `BACK` closed it again: `NEXT` 1264 starts and `BACK` 1241 on // rung 9 after v0.4.2. `MOVE 1` is the backstop now, and it ends the turn. let palette = Palette::for_scene(scene, &mut world); assert_eq!( names(&palette), ["MOVE 1", "MOVE 2", "MOVE 3", "SWITCH", "ITEM"], "three moves with PP, an empty fourth slot, a healthy bench and a potion" ); // Take the bench away and the same frame is one to run from. world.mons.truncate(1); let scene = world.scene(); assert_eq!( names(&Palette::for_scene(scene, &mut world)), ["MOVE 1", "MOVE 2", "MOVE 3", "ITEM", "RUN"] ); } #[test] fn a_move_button_is_bound_by_its_own_slots_pp_and_move_one_carries_struggle() { // Section 14: one button per move slot, asked **over the open move list**, which is where a // slot is a thing to aim at. `World::battle`'s Pokémon has three moves and an empty fourth // slot, so three buttons are on the list's pad and the fourth never is. let mut world = World::battle(); world.list = List::Moves(3); assert!(move_slot_bound(&mut world, MacroKind::Move1)); assert!(move_slot_bound(&mut world, MacroKind::Move2)); assert!(move_slot_bound(&mut world, MacroKind::Move3)); assert!(!move_slot_bound(&mut world, MacroKind::Move4), "an empty slot is not a button"); // A spent slot leaves the pad while a usable one is there: the fly is not offered a move it // cannot use beside one it can. world.mons[0] = mon(0, 4, 20, &[(33, 0), (52, 20)]); assert!(!move_slot_bound(&mut world, MacroKind::Move1)); assert!(move_slot_bound(&mut world, MacroKind::Move2)); // Row 34 of `infra/docs/macros-traps.md`: with **every** slot out of PP the cartridge uses // Struggle, and the way to it is to choose a move anyway. `MOVE 1` stays, and it alone. world.mons[0] = mon(0, 4, 20, &[(33, 0), (52, 0)]); assert!(move_slot_bound(&mut world, MacroKind::Move1), "Struggle is reachable"); assert!(!move_slot_bound(&mut world, MacroKind::Move2)); let scene = world.scene(); assert!( Palette::for_scene(scene, &mut world) .slot(MacroId(MacroKind::Move1.slot())) .is_some() ); // A Pokémon with no move in slot one at all answers no over the list: there is no slot to aim // at, and inventing a press for it is what this crate does not do. world.mons[0] = mon(0, 4, 20, &[]); assert!(!move_slot_bound(&mut world, MacroKind::Move1)); let scene = world.scene(); assert_eq!(Palette::for_scene(scene, &mut world).slot(MacroId(MacroKind::Move1.slot())), None); // Over the **top-level menu** the question is a different one -- section 12.8's "is there a // move list to open" -- and FIGHT always opens, so `MOVE 1` is bound there whatever the seam // makes of the battler. That is what carries the turn now that `NEXT` is off this row // (section 12.10): the script over this menu is "confirm FIGHT and stop" and reads no move. world.list = List::BattleMain; assert!(move_slot_bound(&mut world, MacroKind::Move1), "FIGHT is always pressable"); assert!(!move_slot_bound(&mut world, MacroKind::Move2), "and it is MOVE 1 that carries it"); world.active = None; assert!( move_slot_bound(&mut world, MacroKind::Move1), "a battler the seam cannot read is not a reason to take the turn's one button away" ); } #[test] fn a_move_button_confirms_its_own_slot_over_an_open_list() { // The list is already up: the script goes straight to that slot and confirms, by reading the // cursor rather than by counting presses. let mut world = World::battle(); world.list = List::Moves(3); world.grid = false; world.cursor = 0; world.cursor_max = 2; assert_eq!(run(&mut world, MacroKind::Move3).unwrap(), MacroAbort::Done); assert_eq!(world.cursor, 2, "the third slot, by watching where the cursor went"); assert_eq!(world.pulses.last(), Some(&buttons::A)); // And from the menu above, FIGHT first and then the slot. The list has to actually open: // since section 12.11 the step that walks it *waits* for the move list rather than reading // whatever cursor is up, so a fixture where FIGHT opens nothing waits out `CURSOR_WAIT` -- // which is the right answer, and is what the cartridge was doing to `THROW BALL` in reverse. let mut world = World::battle(); world.opens.push_back(Opens { list: List::Moves(3), cursor: 0, max: 2, grid: false }); assert_eq!(run(&mut world, MacroKind::Move2).unwrap(), MacroAbort::Done); assert!( world.pulses.contains(&buttons::A), "FIGHT was confirmed: {:?}", world.pulses ); } #[test] fn switch_is_unbound_when_the_bench_is_fainted_or_empty() { let mut world = World::battle(); world.mons = vec![mon(0, 4, 20, &[(33, 30)]), mon(1, 0, 20, &[(33, 30)])]; assert!(!precondition(MacroKind::Switch, &mut world)); world.mons.truncate(1); assert!(!precondition(MacroKind::Switch, &mut world)); } #[test] fn item_needs_both_halves_of_its_precondition() { let mut world = World::battle(); assert!(!precondition(MacroKind::Item, &mut world), "hurt but no potion"); world.bag = vec![(item::POTION, 1)]; assert!(precondition(MacroKind::Item, &mut world)); world.mons[0].hp = 19; assert!(!precondition(MacroKind::Item, &mut world), "a potion but above half health"); world.mons[0].hp = 10; assert!(!precondition(MacroKind::Item, &mut world), "exactly half is not below half"); } #[test] fn run_is_bound_in_a_wild_battle_and_unbound_against_a_trainer() { let mut world = World::battle(); // One Pokémon, on four of twenty: a wild battle with nothing to switch to and nothing left to // do but leave. world.mons.truncate(1); assert!(precondition(MacroKind::Run, &mut world)); world.battle = Some((BattleKind::Trainer, true, false)); assert!(!precondition(MacroKind::Run, &mut world), "the cartridge refuses to flee a trainer"); let scene = world.scene(); assert_eq!(Palette::for_scene(scene, &mut world).slots[3], None); } /// Section 13.1, the operator: "we run away a lot". `RUN` used to be bound for every wild battle, so a /// fight the fly was winning was one it could flee -- and a fled battle pays nothing and teaches /// nothing. The precondition is now the two states in which there is nothing else to do. #[test] fn run_is_off_the_pad_in_a_wild_battle_the_fly_is_not_losing() { let mut world = World::battle(); // Full health, moves with PP: not losing, whatever else is true. world.mons = vec![mon(0, 20, 20, &[(33, 30)])]; assert!(!losing(&mut world), "a healthy Pokémon with PP is in a fight worth having"); assert!(!precondition(MacroKind::Run, &mut world)); // Under a third, but something healthier can come in: `SWITCH` is the answer, not `RUN`, and // the two read the same reserve so they cannot both be wrong at once. world.mons = vec![mon(0, 6, 20, &[(33, 30)]), mon(1, 18, 20, &[(33, 30)])]; assert!(healthiest_other(&mut world).is_some()); assert!(!losing(&mut world)); // Under a third with nothing healthier: losing. world.mons = vec![mon(0, 6, 20, &[(33, 30)]), mon(1, 1, 20, &[(33, 30)])]; assert!(losing(&mut world)); // Exactly a third is not under it. world.mons = vec![mon(0, 7, 21, &[(33, 30)])]; assert!(!losing(&mut world), "hp * 3 == max_hp is not under a third"); // Full health, every move out of PP, nothing to switch to: Struggle and recoil, so losing. world.mons = vec![mon(0, 20, 20, &[(33, 0), (52, 0)])]; assert!(losing(&mut world)); // ... but with a reserve, `SWITCH` is the move. world.mons = vec![mon(0, 20, 20, &[(33, 0)]), mon(1, 20, 20, &[(33, 30)])]; assert!(!losing(&mut world)); // And outside a battle it is never true, so nothing can bind `RUN` in the overworld. let mut room = World::room(); assert!(!losing(&mut room)); } #[test] fn a_forced_switch_binds_switch_five_times_and_nothing_on_b() { let mut world = World::battle(); world.scene = Scene::Battle { own_turn: true, forced_switch: true }; world.battle = Some((BattleKind::Wild, true, true)); let scene = world.scene(); let palette = Palette::for_scene(scene, &mut world); assert_eq!(names(&palette), ["NEXT", "SWITCH"]); } #[test] fn a_battle_frame_that_is_not_the_players_turn_binds_next_to_advance_its_text() { // 2026-09-16 hotfix: battle text waits for a press like a dialog (live deadlock on Route 1). let mut world = World::battle(); world.scene = Scene::Battle { own_turn: false, forced_switch: false }; world.battle = Some((BattleKind::Wild, false, false)); world.list = List::None; let scene = world.scene(); // Section 12.9: `NEXT` alone. Section 13.1 put `BACK` here for the bag a battle's ITEM entry // opens, which read as nobody's turn -- but on a frame of text there is no list to leave, and // a `BACK` that changes nothing is the trap of section 12.2 (live, rung 9: 135 of 183 macro // starts). let palette = Palette::for_scene(scene, &mut world); assert_eq!(names(&palette), ["NEXT"]); // Section 12.10: the bag does not land on this arm any more. It is a cursor accepting input, // so it is the fly's turn, and its `NEXT` would have been the A that *uses* what the cursor // holds rather than the A that advances text. Nothing is open here, so nothing but `NEXT` is. world.list = List::BattleBag; world.battle = Some((BattleKind::Wild, true, false)); world.scene = Scene::Battle { own_turn: true, forced_switch: false }; world.bag = vec![(item::POTION, 1)]; let palette = Palette::for_scene(world.scene(), &mut world); assert_eq!(names(&palette), ["BACK", "ITEM"], "a hurt Pokémon, a potion, and no ball"); } #[test] fn the_shop_row_binds_a_purchase_only_when_money_allows() { let mut world = World::room(); world.scene = Scene::Shop; world.list = List::Shop(ShopScreen::BuySellQuit); world.cursor_max = 2; world.stock = vec![item::POKE_BALL, item::POTION]; world.money = price::POKE_BALL; let palette = Palette::for_scene(Scene::Shop, &mut world); assert_eq!(names(&palette), ["CONFIRM", "BUY BALL", "LEAVE"]); world.money = price::POTION; assert!(names(&Palette::for_scene(Scene::Shop, &mut world)).contains(&"BUY POTION")); world.stock = vec![item::POKE_BALL]; assert!( !precondition(MacroKind::BuyPotion, &mut world), "a mart that does not stock potions" ); } /// Section 13's four purchases, each bound by the counter's own stock and by the money on hand. /// /// Viridian's real inventory is the case worth pinning: `data/items/marts.asm` gives it /// POKE BALL, ANTIDOTE, PARLYZ HEAL and BURN HEAL, and **no Potion at all** -- so `BUY POTION` is /// correctly off the pad in the first mart the fly ever walks into, and `BUY ANTIDOTE` is the /// purchase that is on it. #[test] fn the_four_purchases_are_bound_by_the_counters_own_stock() { let mut world = World::room(); world.scene = Scene::Shop; world.list = List::Shop(ShopScreen::Buying); world.cursor_max = 3; // Viridian's counter, in menu order. world.stock = vec![item::POKE_BALL, item::ANTIDOTE, 15, 12]; world.money = 3_000; assert_eq!( names(&Palette::for_scene(Scene::Shop, &mut world)), ["CONFIRM", "BUY BALL", "BUY ANTIDOTE", "LEAVE"], "Viridian stocks no Potion and no Repel" ); // Cerulean's, which stocks both of the other two -- and whose fourth entry is out of the // cursor's reach, exactly as Pewter's Antidote is (row 55): the list scrolls, so only the // first three of a counter's stock have an index this seam can aim at. world.stock = vec![item::POKE_BALL, item::POTION, item::REPEL, item::ANTIDOTE]; assert_eq!( names(&Palette::for_scene(Scene::Shop, &mut world)), ["CONFIRM", "BUY POTION", "BUY BALL", "BUY REPEL", "LEAVE"] ); // Money is the other half, per item: 150 buys an Antidote and nothing else, at a counter // whose Antidote the cursor can reach. world.stock = vec![item::POKE_BALL, item::ANTIDOTE, 15, 12]; world.money = 150; assert_eq!( names(&Palette::for_scene(Scene::Shop, &mut world)), ["CONFIRM", "BUY ANTIDOTE", "LEAVE"] ); // And the cheapest purchase is what the mart errand's money test measures against. assert_eq!(CHEAPEST_PURCHASE, price::ANTIDOTE); } #[test] fn a_shop_whose_stock_cannot_be_read_binds_no_purchase() { // The seam's default: no stock list, so the slot stays unbound rather than guessing an index. let mut world = World::room(); world.scene = Scene::Shop; world.money = 9999; let palette = Palette::for_scene(Scene::Shop, &mut world); assert_eq!(names(&palette), ["CONFIRM", "LEAVE"]); } #[test] fn the_pc_row_is_confirm_and_leave() { let mut world = World::room(); let palette = Palette::for_scene(Scene::Pc, &mut world); assert_eq!(names(&palette), ["CONFIRM", "LEAVE"]); } #[test] fn every_macro_name_fits_the_screen() { for kind in MacroKind::ALL { assert!(kind.name().len() <= 14, "{} is {} characters", kind.name(), kind.name().len()); } } #[test] fn the_healthiest_other_skips_the_active_slot_and_the_fainted() { let mut world = World::battle(); assert_eq!(healthiest_other(&mut world), Some(1)); world.mons.push(mon(3, 20, 20, &[(33, 30)])); assert_eq!(healthiest_other(&mut world), Some(3), "by fraction, not by index"); world.mons[0] = mon(0, 99, 99, &[(33, 30)]); assert_eq!(healthiest_other(&mut world), Some(3), "the active one is never a candidate"); } #[test] fn the_listing_follows_whichever_menu_is_up() { let mut world = World::battle(); assert_eq!(listing(&mut world).map(|list| (list.current, list.max)), Some((0, 3))); world.list = List::Moves(3); world.cursor = 2; assert_eq!(listing(&mut world).map(|list| (list.current, list.max)), Some((2, 2))); world.list = List::BattleParty; world.cursor = 1; assert_eq!(listing(&mut world).map(|list| (list.current, list.max)), Some((1, 2))); // The bag inside a battle was the seam's gap until section 14: `ITEM` could open it and then // had nothing to read, so it waited out `CURSOR_WAIT` and reported `Blocked`. It reports a // cursor now, which is what `ITEM` and `THROW BALL` navigate by. world.list = List::BattleBag; world.cursor_max = 1; world.cursor = 0; assert_eq!(listing(&mut world).map(|list| (list.current, list.max)), Some((0, 1))); } // --------------------------------------------------------------------------------------------- // Section 4: A* and the exits // --------------------------------------------------------------------------------------------- #[test] fn a_star_walks_around_a_wall() { let mut world = World::room().at(0, 0).wall(&[(1, 0), (1, 1), (1, 2)]); let route = path::route(&mut world, &[Tile::new(2, 0)]).expect("a route around the wall"); assert_eq!(route.goal, Some(0)); assert_eq!(route.steps.len(), 8, "down past the wall, across, and back up: {:?}", route.steps); } #[test] fn a_star_picks_the_nearest_of_several_goals() { let mut world = World::room().at(3, 3); let route = path::route(&mut world, &[Tile::new(7, 7), Tile::new(3, 5), Tile::new(0, 0)]) .expect("a route"); assert_eq!(route.goal, Some(1)); assert_eq!(route.steps, vec![Facing::Down, Facing::Down]); } #[test] fn a_star_gives_up_when_nothing_gets_any_closer() { let mut world = World::room().at(0, 0).wall(&[(0, 1), (1, 1), (1, 0)]); assert_eq!(path::route(&mut world, &[Tile::new(4, 4)]), None); } #[test] fn a_goal_outside_the_predicates_window_is_walked_toward_rather_than_refused() { // Agent A's `Walkable::Unknown`: the predicate can only answer near the player, so the door is // off the screen buffer. The search walks toward it anyway at [`UNKNOWN_STEP`] a tile, because // a map edge is off the window by definition and a search that refused every unknown tile // could not plan one step toward Route 1 from the middle of Pallet Town. The window travels // along and the walk re-plans every tile. let mut world = World::room().at(0, 0); for y in 0..8u8 { for x in 0..8u8 { if u32::from(x) + u32::from(y) > 4 { world.unseen.insert(Tile::new(x, y)); } } } let route = path::route(&mut world, &[Tile::new(7, 7)]).expect("a route through the unknown"); assert!(!route.steps.is_empty(), "it moved toward the door"); assert!( matches!(route.steps[0], Facing::Right | Facing::Down), "and toward it rather than away: {:?}", route.steps[0] ); // A wall is still a wall: unknown is a price, not a claim, and nothing paths through `No`. let mut walled = World::room().at(0, 0).wall(&[(0, 1), (1, 1), (1, 0)]); assert_eq!(path::route(&mut walled, &[Tile::new(4, 4)]), None); // And a known way round always beats the unknown one: the detour is four tiles, the straight // line through the unseen middle is two. let mut round = World::room().at(0, 0); for y in 1..3u8 { round.unseen.insert(Tile::new(1, y)); } let route = path::route(&mut round, &[Tile::new(1, 3)]).expect("a route"); assert_eq!(route.goal, Some(0)); assert_eq!( route.steps.len(), 4, "it went the four known tiles round rather than the two unseen ones through: {:?}", route.steps ); assert!(!route.steps.contains(&Facing::Left)); } #[test] fn standing_on_the_goal_is_a_route_with_no_steps() { let mut world = World::room().at(3, 3); let route = path::route(&mut world, &[Tile::new(3, 3)]).expect("a zero step route"); assert_eq!(route, path::Route { goal: Some(0), steps: Vec::new() }); } #[test] fn the_player_tile_is_passable_even_when_the_predicate_says_otherwise() { // A doormat the game will not let the player stop on, with the player standing on it. let mut world = World::room().at(3, 7).wall(&[(3, 7)]); assert!(path::route(&mut world, &[Tile::new(3, 3)]).is_some()); } #[test] fn exits_lists_the_warps_and_every_connected_edge_tile() { let mut world = World::ground_floor(); assert_eq!(path::exits(&mut world).len(), 3, "three warps and no connections"); world.connections.south = true; assert_eq!(path::exits(&mut world).len(), 3 + 8, "the whole bottom row is an edge exit too"); } #[test] fn a_doormat_on_the_bottom_row_carries_the_outward_press() { let mut world = World::ground_floor(); let exits = path::exits(&mut world); let mat = exits .iter() .find(|exit| exit.tile == Tile::new(2, 7)) .expect("the left doormat is an exit"); assert_eq!(mat.press, Some(Facing::Down), "the front door needs DOWN, and only DOWN"); let stairs = exits .iter() .find(|exit| exit.tile == Tile::new(7, 1)) .expect("the staircase is an exit"); assert_eq!(stairs.press, None, "an interior warp fires on the step onto it"); } #[test] fn a_way_out_is_visited_only_when_the_map_on_the_other_side_is() { // Section 9.2, after the town loop: standing on a doormat, or beside it, is what the // adapter's `boundary` ledger pays for, and it is no longer what "visited" means. Both // doormats of the ground floor lead to Pallet Town; the staircase leads upstairs. let mut world = World::ground_floor(); world.visited.insert(ExitId::Warp(1)); let tiles: Vec = ways(&mut world, Way::Exit).iter().map(|exit: &Exit| exit.tile).collect(); assert_eq!( tiles, vec![Tile::new(2, 7), Tile::new(3, 7)], "the boundary ledger does not decide this any more: {tiles:?}" ); // The map on the other side, which is the question that does decide it. world.seen_maps.insert(0x00); assert_eq!( ways(&mut world, Way::Exit).len(), 2, "a room whose outside is known still has to be left, and nearest picks" ); // And upstairs is a different map, asked separately. assert_eq!(ways(&mut world, Way::Passage).len(), 1); world.seen_maps.insert(0x26); assert!( ways(&mut world, Way::Passage).is_empty(), "a staircase already been up has no fallback on a floor with its own front door: up and \ straight back down was the same bounce as the door's (2026-09-17)" ); } #[test] fn a_connection_to_an_unvisited_map_outranks_a_door_into_an_unvisited_interior() { // Section 9.2: "rank connections to unvisited maps above doors to unvisited interiors". Pallet // Town with a door into a house nobody has been in and the connection north to Route 1, which // is the choice the fly got wrong for an hour on the stream. let mut world = World::room(); world.map = maps::PALLET_TOWN; world.warps = vec![Warp { x: 2, y: 4, destination_warp: 0, destination_map: maps::BLUES_HOUSE }]; world.connections.north = true; let routes = ways(&mut world, Way::Route); assert!( routes.iter().all(|exit: &Exit| matches!(exit.id, ExitId::Edge(Edge::North))), "the connection wins outright: {routes:?}" ); assert_eq!(routes[0].into, Some(maps::ROUTE_1), "and it knows where it goes"); // Once Route 1 has been walked, the door is what is left. world.seen_maps.insert(maps::ROUTE_1); let routes = ways(&mut world, Way::Route); assert_eq!(routes.len(), 1); assert_eq!(routes[0].tile, Tile::new(2, 4)); // And with both known, the exhausted fallback is the exit toward the objective. world.seen_maps.insert(maps::BLUES_HOUSE); world.objective = Some(Objective { map: maps::VIRIDIAN_CITY, tile: None, warp: None, edge: None, target: None }); let routes = ways(&mut world, Way::Route); assert!( routes.iter().all(|exit: &Exit| matches!(exit.id, ExitId::Edge(Edge::North))), "north is the way to Viridian: {routes:?}" ); } #[test] fn go_route_prefers_a_door_whose_interior_this_run_has_not_seen() { // Outdoors, with two doors: one into a house this run has been in and one into a house it has // not (section 9.1, "a door into a building whose interior is unvisited"). let mut world = World::room(); world.map = 0x00; world.warps = vec![ Warp { x: 2, y: 0, destination_warp: 0, destination_map: 0x25 }, Warp { x: 5, y: 0, destination_warp: 0, destination_map: 0x28 }, ]; world.seen_maps.insert(0x25); let tiles: Vec = ways(&mut world, Way::Route).iter().map(|exit: &Exit| exit.tile).collect(); assert_eq!(tiles, vec![Tile::new(5, 0)], "the unvisited interior wins: {tiles:?}"); // And with both interiors seen, `GO ROUTE` has nothing: no fresh destination, nothing on the // way to an objective, and no last-resort fallback to the nearest door (2026-09-17). That // fallback is what let the fly walk into the same house once per hold for two hours. world.seen_maps.insert(0x28); assert!( ways(&mut world, Way::Route).is_empty(), "every route leads somewhere this run has been, so the button leaves the pad" ); assert!(!on_the_pad(&mut world, MacroKind::GoRoute)); // A way *out of a building* keeps its fallback: a room still has to be leavable. let mut indoors = World::ground_floor(); indoors.seen_maps.extend([0x00, 0x26, 0xff]); assert_eq!(ways(&mut indoors, Way::Exit).len(), 2, "both doormats, visited or not"); // The staircase does not, on a floor that has its own front door: up and straight back down is // the same bounce measured on the stairs instead of the door (2026-09-17). assert!(ways(&mut indoors, Way::Passage).is_empty(), "the upper floor has been seen"); assert!(!on_the_pad(&mut indoors, MacroKind::GoWarp)); // But a map whose only way anywhere is a passage keeps it, or the fly is stranded upstairs. let mut bedroom = World::room(); bedroom.map = 0x26; bedroom.warps = vec![Warp { x: 7, y: 1, destination_warp: 2, destination_map: 0x25 }]; bedroom.seen_maps.insert(0x25); assert!(ways(&mut bedroom, Way::Exit).is_empty(), "no front door up here"); assert_eq!(ways(&mut bedroom, Way::Passage).len(), 1, "so the staircase is still offered"); assert!(on_the_pad(&mut bedroom, MacroKind::GoWarp)); } #[test] fn an_edge_exit_is_named_by_its_edge() { let mut world = World::room(); world.connections.north = true; let exits = path::exits(&mut world); assert!(exits.iter().all(|exit| exit.id == ExitId::Edge(Edge::North))); assert!(exits.iter().all(|exit| exit.press == Some(Facing::Up))); } // --------------------------------------------------------------------------------------------- // Section 4: refusals // --------------------------------------------------------------------------------------------- #[test] fn an_unbound_slot_is_refused_and_nothing_is_pressed() { let mut world = World::room(); let palette = Palette::for_scene(Scene::Overworld, &mut world); let mut machine = MacroMachine::new(1); let refused = machine.start(&palette, MacroId(0), &mut world).expect_err("no exit here"); assert_eq!(refused, MacroRefused { slot: MacroId(0), reason: Refusal::Unbound }); assert!(machine.step(&mut world).is_none(), "a refusal presses nothing"); assert!(world.pulses.is_empty()); assert_eq!(machine.outcome().map(|(_, outcome)| outcome), Some(MacroAbort::Refused)); } #[test] fn a_slot_past_the_end_of_the_palette_is_unbound() { let mut world = World::room(); let palette = Palette::for_scene(Scene::Overworld, &mut world); let mut machine = MacroMachine::new(1); let refused = machine.start(&palette, MacroId(9), &mut world).expect_err("there are six"); assert_eq!(refused.reason, Refusal::Unbound); } #[test] fn a_second_start_while_a_macro_owns_the_buttons_is_refused() { let mut world = World::room(); let (palette, slot) = pick(&mut world, MacroKind::GoFrontier); let mut machine = MacroMachine::new(1); machine.start(&palette, slot, &mut world).expect("GO FRONTIER starts"); assert_eq!(machine.running(), Some("GO FRONTIER")); assert_eq!( machine.start(&palette, slot, &mut world).expect_err("busy").reason, Refusal::Busy ); } #[test] fn a_palette_for_another_scene_is_refused() { let mut world = World::room(); let menu = Palette::for_scene(Scene::Menu, &mut world); let mut machine = MacroMachine::new(1); assert_eq!( machine .start(&menu, MacroId(MacroKind::Close.slot()), &mut world) .expect_err("wrong scene") .reason, Refusal::WrongScene ); assert!(world.pulses.is_empty()); } #[test] fn a_precondition_that_lapsed_since_the_palette_was_dealt_refuses() { let mut world = World::battle(); world.mons.truncate(1); let scene = world.scene(); let palette = Palette::for_scene(scene, &mut world); let run_slot = MacroId(MacroKind::Run.slot()); assert!(palette.slot(run_slot).is_some(), "RUN was bound when the palette was dealt"); world.battle = Some((BattleKind::Trainer, true, false)); let mut machine = MacroMachine::new(1); assert_eq!( machine.start(&palette, run_slot, &mut world).expect_err("not wild now").reason, Refusal::Precondition ); assert!(world.pulses.is_empty()); } #[test] fn go_out_refuses_when_there_is_nothing_to_walk_toward() { let mut world = World::ground_floor().at(0, 0).wall(&[(0, 1), (1, 1), (1, 0)]); assert_eq!( run(&mut world, MacroKind::GoOut).expect_err("walled in").reason, Refusal::NoRoute ); } // --------------------------------------------------------------------------------------------- // Section 4: the scripts // --------------------------------------------------------------------------------------------- #[test] fn talk_presses_a_exactly_once_and_records_what_it_faced() { // Section 12: the press is on the pad only while the tile ahead holds something untalked, so // the world has to have something in front of the fly for `TALK` to be startable at all. let mut world = World::room().at(3, 3); world.facing = Facing::Down; world.npcs = vec![Npc { slot: 4, picture: 1, x: 3, y: 4, facing: Facing::Up }]; let mut machine = MacroMachine::new(1); let (palette, slot) = pick(&mut world, MacroKind::Talk); machine.start(&palette, slot, &mut world).expect("TALK is bound at a villager"); for _ in 0..(FRAME_CAP + 64) { match machine.step(&mut world) { Some(mask) => world.frame(mask), None => break, } } assert_eq!(machine.outcome().expect("a finished macro").1, MacroAbort::Done); assert_eq!(world.pulses, vec![buttons::A]); // The press is a conversation *begun*: nothing is in the ledger until the box has closed with // the fly where it pressed from (`docs/design/macros.md` section 12.4), which is the frame the // driver hands the machine next. assert_eq!(machine.take_talked(), None, "the conversation has not ended yet"); machine.observe_frame(&mut world); // And then the ledger entry is the villager's sprite slot on this map, taken once. assert_eq!(machine.take_talked(), Some((world.map, TalkTarget::Sprite(4)))); assert_eq!(machine.take_talked(), None, "taken rather than read"); // A press that never finishes records nothing. let mut cancelled = World::room().at(3, 3); cancelled.facing = Facing::Down; cancelled.npcs = vec![Npc { slot: 4, picture: 1, x: 3, y: 4, facing: Facing::Up }]; let (palette, slot) = pick(&mut cancelled, MacroKind::Talk); let mut machine = MacroMachine::new(1); machine.start(&palette, slot, &mut cancelled).unwrap(); machine.cancel(); assert_eq!(machine.take_talked(), None); } #[test] fn go_item_walks_next_to_the_nearest_object_and_turns_to_face_it() { // A ball on a table in the middle of the room and a sign on the far wall: the ball is nearer, // and the tile to stand on is beside it rather than on it. let mut world = World::room().at(1, 1).wall(&[(1, 4), (7, 7)]); world.npcs = vec![ Npc { slot: 1, picture: 3, x: 2, y: 1, facing: Facing::Down }, Npc { slot: 2, picture: BALL_SPRITE, x: 1, y: 4, facing: Facing::Down }, ]; world.signs = vec![Sign { x: 7, y: 7, text_id: 1 }]; assert_eq!(run(&mut world, MacroKind::GoItem).unwrap(), MacroAbort::Done); assert_eq!(world.player, Tile::new(1, 3), "one tile above the ball"); assert_eq!(world.facing, Facing::Down, "facing it"); assert!( world.pulses.iter().all(|mask| *mask == buttons::DOWN), "straight down and then a turn that was already down: {:?}", world.pulses ); } #[test] fn go_item_reaches_a_sign_in_a_wall_from_the_one_side_it_has() { // A signpost is part of a wall: three of the four tiles around it are wall too, and only the // tile below it can be stood on. The route search finds that out; nothing tells it. let mut world = World::room().at(3, 3).wall(&[(2, 0), (1, 0), (3, 0)]); world.signs = vec![Sign { x: 2, y: 0, text_id: 1 }]; assert_eq!(run(&mut world, MacroKind::GoItem).unwrap(), MacroAbort::Done); assert_eq!(world.player, Tile::new(2, 1), "below the sign, which is the only way to it"); assert_eq!(world.facing, Facing::Up, "looking at it"); } #[test] fn go_item_refuses_when_the_object_cannot_be_reached() { // The fly walled into a corner with the ball across the room: the precondition holds — there // *is* an object — and the route search is what refuses, before a button is pressed. A ball // merely walled *in* is different and is not a refusal: the search returns the route that // gets closest to it, the window travels along, and the walk runs out of failed steps. let mut world = World::room().at(0, 0).wall(&[(0, 1), (1, 0), (1, 1)]); world.npcs = vec![Npc { slot: 1, picture: BALL_SPRITE, x: 5, y: 5, facing: Facing::Down }]; assert!(precondition(MacroKind::GoItem, &mut world)); assert_eq!( run(&mut world, MacroKind::GoItem).expect_err("no way in").reason, Refusal::NoRoute ); assert!(world.pulses.is_empty(), "nothing was pressed: {:?}", world.pulses); } #[test] fn next_presses_a_and_no_presses_b() { let mut world = World::room(); world.scene = Scene::Dialog; world.b_to_close = u8::MAX; assert_eq!(run(&mut world, MacroKind::Next).unwrap(), MacroAbort::Done); assert_eq!(world.pulses, vec![buttons::A]); let mut world = World::room(); world.scene = Scene::Dialog; world.b_to_close = u8::MAX; assert_eq!(run(&mut world, MacroKind::No).unwrap(), MacroAbort::Done); assert_eq!(world.pulses, vec![buttons::B]); } #[test] fn yes_presses_a() { let mut world = World::room(); world.scene = Scene::Dialog; assert_eq!(run(&mut world, MacroKind::Yes).unwrap(), MacroAbort::Done); assert_eq!(world.pulses, vec![buttons::A]); } #[test] fn go_frontier_walks_to_new_ground_and_never_rolls_a_die() { // Section 9: `GO FRONTIER` replaces `WANDER`, and "no random steps remain anywhere in the // palette". The room's right-hand column is the only ground this run has not stood on, so the // walk is rightward and nothing about it depends on the seed. let mut world = World::room().at(0, 0); for y in 0..8 { for x in 0..7 { world.stood.insert(Tile::new(x, y)); } } assert_eq!(run(&mut world, MacroKind::GoFrontier).unwrap(), MacroAbort::Done); assert!( world.pulses.iter().all(|mask| *mask == buttons::RIGHT), "straight at the frontier: {:?}", world.pulses ); assert_eq!(world.player.x, 7, "and it stood on the new ground: {:?}", world.player); assert_eq!(world.facing, Facing::Right, "having faced it to get there"); // Two runs with different seeds press exactly the same buttons, which is the "no random // steps" claim as a test rather than as a sentence. let pressed = |seed: u32| { let mut world = World::room().at(0, 0); for y in 0..8 { for x in 0..7 { world.stood.insert(Tile::new(x, y)); } } let (palette, slot) = pick(&mut world, MacroKind::GoFrontier); let mut machine = MacroMachine::new(seed); machine.start(&palette, slot, &mut world).expect("GO FRONTIER starts"); while let Some(mask) = machine.step(&mut world) { world.frame(mask); } world.pulses.clone() }; assert_eq!(pressed(1), pressed(0xdead_beef)); } #[test] fn go_frontier_is_unbound_once_every_reachable_tile_has_been_stood_on() { let mut world = World::room(); for y in 0..8 { for x in 0..8 { world.stood.insert(Tile::new(x, y)); } } assert!(!precondition(MacroKind::GoFrontier, &mut world)); let palette = Palette::for_scene(Scene::Overworld, &mut world); assert_eq!(palette.slots[3], None, "nothing is left to explore, so the slot is no action"); } #[test] fn menu_holds_start_until_the_start_menu_opens() { let mut world = World::room(); world.start_to_open = 2; // Off every pad since section 12.11, so the palette is built by hand: the script is what is // under test here and no scene offers the button any more. let mut machine = MacroMachine::new(0x1234_5678); let (palette, slot) = forced(MacroKind::Menu, &mut world); assert_eq!(drive(&mut machine, &palette, slot, &mut world).unwrap(), MacroAbort::Done); assert_eq!(world.pulses, vec![buttons::START, buttons::START]); assert_eq!(world.scene, Scene::Menu); } #[test] fn menu_reports_blocked_when_the_start_menu_never_opens() { let mut world = World::room(); world.start_to_open = u8::MAX; let mut machine = MacroMachine::new(0x1234_5678); let (palette, slot) = forced(MacroKind::Menu, &mut world); assert_eq!(drive(&mut machine, &palette, slot, &mut world).unwrap(), MacroAbort::Blocked); } #[test] fn close_backs_out_of_a_submenu_and_stops_at_the_overworld() { let mut world = World::room(); world.scene = Scene::Menu; world.list = List::Start; world.cursor_max = 5; world.b_to_close = 3; assert_eq!(run(&mut world, MacroKind::Close).unwrap(), MacroAbort::Done); assert_eq!(world.pulses, vec![buttons::B; 3]); assert_eq!(world.scene, Scene::Overworld); } #[test] fn close_reports_blocked_when_the_menu_never_closes() { let mut world = World::room(); world.scene = Scene::Menu; world.list = List::Start; world.b_to_close = u8::MAX; assert_eq!(run(&mut world, MacroKind::Close).unwrap(), MacroAbort::Blocked); } #[test] fn go_out_walks_to_the_front_door_and_steps_through_it() { let mut world = World::ground_floor().at(3, 4); assert_eq!(run(&mut world, MacroKind::GoOut).unwrap(), MacroAbort::Done); assert_eq!(world.map, 0x00, "the fly left the house"); assert!( world.pulses.iter().all(|mask| *mask == buttons::DOWN), "three tiles straight down: {:?}", world.pulses ); } #[test] fn go_out_takes_a_sideways_doormat_and_then_presses_down() { // Standing next to the left mat: `GO OUT` is pointed at the doormats and not at the // staircase, so the route is one step left onto the mat, which does nothing on its own, and // then the outward press. let mut world = World::ground_floor().at(4, 7); assert_eq!(run(&mut world, MacroKind::GoOut).unwrap(), MacroAbort::Done); assert_eq!(world.map, 0x00); // The outward press was still held when the door opened, so it never released into `pulses`. assert_eq!(world.pulses, vec![buttons::LEFT]); assert_eq!(world.previous, buttons::DOWN, "and DOWN is what opened it"); } #[test] fn go_warp_reaches_an_interior_warp_without_an_extra_press() { let mut world = World::ground_floor().at(7, 3); assert_eq!(run(&mut world, MacroKind::GoWarp).unwrap(), MacroAbort::Done); assert_eq!(world.map, 0x26, "up the stairs"); assert_eq!(world.pulses, vec![buttons::UP], "the second press fired the staircase"); assert_eq!(world.previous, buttons::UP); } #[test] fn go_out_leaves_by_a_connected_map_edge() { let mut world = World::room().at(3, 5); world.connections.south = true; assert_eq!(run(&mut world, MacroKind::GoOut).unwrap(), MacroAbort::Done); assert_eq!(world.map, 0xfe); } #[test] fn three_steps_that_do_not_move_the_player_abort_as_blocked() { let mut world = World::ground_floor().at(3, 4); world.glue = true; assert_eq!(run(&mut world, MacroKind::GoOut).unwrap(), MacroAbort::Blocked); assert_eq!(world.player, Tile::new(3, 4), "and it never moved"); } #[test] fn a_walk_longer_than_the_old_cap_finishes_because_it_keeps_making_progress() { // The Viridian stall's own arithmetic, from the other side (2026-09-17, // `infra/docs/macros-traps.md`): a corridor forty tiles long, a tile costing most of a second, // and a door at the far end. Under section 4's flat 600-frame cap this walk could not finish — // and the fly, which chooses the macro and not the route, had no way to ask for more frames. // The budget is the plan's now ([`walk_budget`]) and a walk still closing on its goal is not // out of frames at all, so the corridor is walked and the door is reached. let mut world = World::room().at(0, 0); world.size = MapSize { width: 1, height: 40 }; world.warps = vec![Warp { x: 0, y: 39, destination_warp: 1, destination_map: 0x00 }]; world.tile_frames = 20; assert_eq!(run(&mut world, MacroKind::GoOut).unwrap(), MacroAbort::Done); assert_eq!(world.map, 0x00, "it arrived: {:?}", world.player); assert!( world.frames > FRAME_CAP, "and it took more than the old cap to do it: {} frames", world.frames ); } #[test] fn the_walk_budget_is_the_plan_plus_a_floor_and_stops_at_a_minute() { // `docs/design/macros.md` section 12.1, amended 2026-09-17: "24 frames per planned tile plus a // floor, capped at 60 s brain time". The floor is section 4's own ten seconds, so a one-tile // walk is budgeted exactly as every other script is and nothing short got shorter. assert_eq!(walk_budget(0), FRAME_CAP); assert_eq!(walk_budget(1), FRAME_CAP + FRAMES_PER_PLANNED_TILE); // Viridian City is about twenty tiles by eighteen: the walk across it now has the frames. assert!(walk_budget(38) > 1_400, "a walk across Viridian: {}", walk_budget(38)); // And a plan no walk should own the pad for is a minute, whatever its length. assert_eq!(walk_budget(10_000), WALK_FRAME_CEILING); assert_eq!(walk_budget(usize::MAX), WALK_FRAME_CEILING); // 59.7275 frames a second, which is the emulator's rate everywhere in this workspace. assert_eq!(WALK_FRAME_CEILING, (60.0 * 59.7275) as u32); } #[test] fn a_walk_the_ceiling_cuts_short_resumes_from_where_it_stopped() { // The other half of the amendment: "a walk interrupted by the cap resumes from where it // stopped on the next start". A corridor far longer than a minute of walking, so the ceiling // is what ends the first walk; the second one carries the same route on instead of planning the // same first tiles again. let mut world = World::room().at(0, 0); world.size = MapSize { width: 1, height: 200 }; world.warps = vec![Warp { x: 0, y: 199, destination_warp: 1, destination_map: 0x00 }]; let mut machine = MacroMachine::new(0x1234_5678); assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoOut).unwrap(), MacroAbort::Timeout); let first = world.player.y; assert!(first > 0 && first < 199, "it walked part of the way: {:?}", world.player); assert_eq!(world.map, 0x25, "and it did not arrive"); // The cap is not a fact about the target: the exit keeps its place on the pad. assert!(on_the_pad(&mut world, MacroKind::GoOut)); // The next start carries the same route on and finishes the journey. assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoOut).unwrap(), MacroAbort::Done); assert_eq!(world.map, 0x00, "the resumed walk arrived: {:?}", world.player); // A route is a list of directions *from one tile*, so a resume is only honest from the tile the // walk was suspended at. Between two holds the fly is free to take a warp, lose a battle or be // rolled back, and following a stale route then would walk it along somebody else's path: the // same corridor, suspended and then restarted from the top, plans afresh and gets no further // than the first walk did. let mut moved = World::room().at(0, 0); moved.size = MapSize { width: 1, height: 200 }; moved.warps = vec![Warp { x: 0, y: 199, destination_warp: 1, destination_map: 0x00 }]; let mut machine = MacroMachine::new(0x1234_5678); assert_eq!(run_with(&mut machine, &mut moved, MacroKind::GoOut).unwrap(), MacroAbort::Timeout); let suspended = moved.player; moved.player = Tile::new(0, 0); assert_eq!(run_with(&mut machine, &mut moved, MacroKind::GoOut).unwrap(), MacroAbort::Timeout); assert_eq!(moved.map, 0x25, "the stale route was not followed to the door"); assert!( moved.player.y <= suspended.y, "it planned afresh from the top: {:?} against {suspended:?}", moved.player ); } #[test] fn a_goal_walled_off_from_the_fly_is_excluded_without_a_press() { // **The 2026-09-17 stall, reproduced.** Eight hours on rung 8 in Viridian City: 164 walks, 165 // timeouts, not one macro finishing, and every walk spending its whole 600 frames over *two* // tiles with a net displacement of zero (`infra/docs/macros-traps.md`). The mechanism is the // closest-approach answer: with no goal reachable, `path::route` returns the route to the tile // that gets nearest one, the walk takes a step, the re-plan from the new tile answers with the // tile it just left, and the two of them trade the fly back and forth for the whole cap. The // frame cap then read as "closing" — it had been one tile nearer once — so it cost a strike // rather than excluding anything, and ten brain minutes later the same walk was available // again. // // A goal the search cannot reach is now excluded at `start`: nothing is pressed, the key goes // to the blocked ledger, and the button leaves the pad when the list empties. let mut world = World::room().at(0, 0); world.map = 0x00; // An item ball sealed in the far corner behind two walls: all four tiles beside it are either // walls or enclosed by them. world.npcs = vec![Npc { slot: 2, picture: BALL_SPRITE, x: 7, y: 7, facing: Facing::Down }]; world.walls.extend([Tile::new(6, 6), Tile::new(6, 7), Tile::new(7, 6), Tile::new(5, 7)]); let ball = TargetKey::Thing(TalkTarget::Sprite(2)); assert!(on_the_pad(&mut world, MacroKind::GoItem), "the ball is a candidate to begin with"); let refused = run(&mut world, MacroKind::GoItem).expect_err("no route to any of the four"); assert_eq!(refused.reason, Refusal::NoRoute); assert!(world.pulses.is_empty(), "and nothing was pressed: {:?}", world.pulses); assert_eq!(world.player, Tile::new(0, 0), "the fly did not move a tile"); assert!(world.targets.blocked(world.map, ball), "the ball is excluded, not retried"); assert!(!on_the_pad(&mut world, MacroKind::GoItem), "so the button leaves the pad"); } #[test] fn a_step_the_cartridge_refuses_is_a_wall_in_that_direction_only() { // What the collision table cannot answer, and what the walk measures instead // ([`path::Refusal`]): a ledge is a facing/tile/tile triple in `LedgeTiles` and a tile-pair // collision refuses a step between two tiles that are each passable, so neither shows up in // `walkable`. A step that spends its whole window with the player where it started is // recorded as a directed wall and the re-plan goes round it — without which the walk spends // its three failures re-planning the same refused step. let mut world = World::room().at(0, 3); world.size = MapSize { width: 4, height: 8 }; world.warps = vec![Warp { x: 3, y: 3, destination_warp: 1, destination_map: 0x00 }]; // The direct way east out of each tile of row 3 is refused, as a ledge along it would be. for x in 0..3 { world.refuse.push((Tile::new(x, 3), Facing::Right)); } assert_eq!(run(&mut world, MacroKind::GoOut).unwrap(), MacroAbort::Done); assert_eq!(world.map, 0x00, "it went round: {:?}", world.player); // Directed: the same refusal does not close the way back. A walk from the far side crosses // row 3 westward with nothing in its way. let mut back = World::room().at(3, 3); back.size = MapSize { width: 4, height: 8 }; back.warps = vec![Warp { x: 0, y: 3, destination_warp: 1, destination_map: 0x00 }]; for x in 0..3 { back.refuse.push((Tile::new(x, 3), Facing::Right)); } assert_eq!(run(&mut back, MacroKind::GoOut).unwrap(), MacroAbort::Done); assert_eq!(back.map, 0x00); } #[test] fn the_walk_commits_to_its_route_across_the_walkable_windows_edge() { // The window is agent A's ten-by-nine screen buffer and it *moves with the player* // (`pokemon_red::state::walkable`), so an A* re-planned after every tile is an A* over a // different map after every tile: a tile priced as plausible ground at eight // (`path::UNKNOWN_STEP`) becomes a wall as the window reaches it, the cheapest route flips to // the far side of the obstacle, and the step back makes the tile unknown again. The route is // therefore planned once and followed while the player is still on it. // // A map wider than the window, with the way out at the far end: the fly cannot see the exit // when it sets off and has to keep walking on a plan made through unknown ground. let mut world = World::room().at(1, 4); world.size = MapSize { width: 24, height: 9 }; world.window = true; world.warps = vec![Warp { x: 23, y: 4, destination_warp: 1, destination_map: 0x00 }]; assert_eq!(run(&mut world, MacroKind::GoOut).unwrap(), MacroAbort::Done); assert_eq!(world.map, 0x00, "it crossed the map: {:?}", world.player); // The signature of the loop this replaced is a reversal: a step back the way the walk came. // There is none, because the plan was not remade under the fly's feet. let steps: Vec = world.pulses.iter().copied().filter(|mask| direction_of(*mask).is_some()).collect(); assert!(!steps.is_empty()); assert!( steps.iter().all(|mask| *mask == buttons::RIGHT), "the walk went one way and kept going: {steps:?}" ); } #[test] fn a_scene_change_under_a_running_macro_ends_it_as_done() { let mut world = World::ground_floor().at(3, 0); world.switch = Some((40, Scene::Battle { own_turn: true, forced_switch: false })); assert_eq!(run(&mut world, MacroKind::GoOut).unwrap(), MacroAbort::Done); assert_ne!(world.player, Tile::new(3, 7), "the walk was cut short"); assert_eq!(world.map, 0x25, "and the door was never reached"); } #[test] fn go_npc_walks_next_to_the_nearest_person_and_turns_to_face_it() { // Outdoors, because that is where `GO NPC` is on a channel (section 9.1); indoors it is a // plan fallback and the script under test is the same either way. let mut world = World::room().at(1, 1).wall(&[(1, 4), (6, 6)]); world.map = 0x00; world.npcs = vec![ Npc { slot: 1, picture: 1, x: 6, y: 6, facing: Facing::Up }, Npc { slot: 2, picture: 2, x: 1, y: 4, facing: Facing::Up }, ]; assert_eq!(run(&mut world, MacroKind::GoNpc).unwrap(), MacroAbort::Done); assert_eq!(world.player, Tile::new(1, 3), "one tile above the nearer sprite"); assert_eq!(world.facing, Facing::Down, "facing it"); } #[test] fn go_npc_walks_past_a_nearer_object_to_reach_a_person() { // Oak's lab in miniature: a ball one tile away and a person across the room. `GO NPC` is // "nearest person", so the ball is not a candidate however close it is. let mut world = World::room().at(1, 1).wall(&[(1, 2), (6, 6)]); world.map = 0x00; world.npcs = vec![ Npc { slot: 1, picture: BALL_SPRITE, x: 1, y: 2, facing: Facing::Down }, Npc { slot: 2, picture: 3, x: 6, y: 6, facing: Facing::Up }, ]; assert_eq!(run(&mut world, MacroKind::GoNpc).unwrap(), MacroAbort::Done); assert!( Tile::new(6, 6).distance(world.player) == 1, "it ended up beside the person, not the ball: {:?}", world.player ); } #[test] fn a_move_button_selects_fight_and_then_its_own_slot() { let mut world = World::battle(); // A on FIGHT opens the move list, a plain column of three. world.opens.push_back(Opens { list: List::Moves(3), cursor: 0, max: 2, grid: false }); assert_eq!(run(&mut world, MacroKind::Move1).unwrap(), MacroAbort::Done); assert_eq!(world.list, List::Moves(3)); assert_eq!(world.cursor, 0, "slot one, because that is the button that was pressed"); assert_eq!(world.pulses.iter().filter(|mask| **mask == buttons::A).count(), 2); } #[test] fn a_move_button_walks_the_list_to_its_own_slot() { let mut world = World::battle(); world.opens.push_back(Opens { list: List::Moves(3), cursor: 0, max: 2, grid: false }); assert_eq!(run(&mut world, MacroKind::Move3).unwrap(), MacroAbort::Done); assert_eq!(world.cursor, 2, "two DOWN presses, each one read back"); assert_eq!(world.pulses.iter().filter(|mask| **mask == buttons::DOWN).count(), 2); } #[test] fn move_one_confirms_the_cursor_when_nothing_has_pp_so_struggle_happens() { // Row 30a and row 34 together: `CheckPlayerHasUsableMoves` prints "has no moves left!" and // sets Struggle *without opening the list*, so confirming FIGHT is the whole macro and a // second cursor step would press A at text. let mut world = World::battle(); world.mons[0] = mon(0, 4, 20, &[(33, 0), (52, 0), (45, 0)]); assert_eq!(run(&mut world, MacroKind::Move1).unwrap(), MacroAbort::Done); assert_eq!( world.pulses.iter().filter(|mask| **mask == buttons::A).count(), 1, "FIGHT and nothing after it: {:?}", world.pulses ); // With the list already open and everything spent, it confirms where the cursor stands. let mut world = World::battle(); world.mons[0] = mon(0, 4, 20, &[(33, 0), (52, 0), (45, 0)]); world.list = List::Moves(3); world.grid = false; world.cursor = 1; world.cursor_max = 2; assert_eq!(run(&mut world, MacroKind::Move1).unwrap(), MacroAbort::Done); assert_eq!(world.cursor, 1, "wherever it was, so Struggle happens"); assert_eq!(world.pulses.last(), Some(&buttons::A)); } #[test] fn a_cursor_reaches_the_far_corner_of_reds_two_by_two_menu() { // RUN is the fourth entry of a grid, which no number of DOWN presses can reach. The cursor is // read after every press, so the script finds that out and changes axis. let mut world = World::battle(); // One Pokémon on four of twenty: `RUN` is on the pad only in a wild battle the fly is losing // (section 13.1), and this test is about the cursor rather than about the precondition. world.mons.truncate(1); assert_eq!(run(&mut world, MacroKind::Run).unwrap(), MacroAbort::Done); assert_eq!(world.cursor, battle_entry::RUN, "RUN, by watching where the cursor went"); assert!(world.pulses.contains(&buttons::RIGHT), "it needed the other axis: {:?}", world.pulses); assert_eq!(world.pulses.last(), Some(&buttons::A)); } #[test] fn switch_picks_the_healthiest_bench_entry_through_the_party_list() { let mut world = World::battle(); world.opens.push_back(Opens { list: List::BattleParty, cursor: 0, max: 2, grid: false }); assert_eq!(run(&mut world, MacroKind::Switch).unwrap(), MacroAbort::Done); assert_eq!(world.cursor, 1, "the healthy bench entry"); assert_eq!( world.pulses.iter().filter(|mask| **mask == buttons::A).count(), 3, "PKMN, the party entry, then SWITCH: {:?}", world.pulses ); } #[test] fn a_forced_switch_goes_straight_to_the_party_list() { let mut world = World::battle(); world.scene = Scene::Battle { own_turn: true, forced_switch: true }; world.battle = Some((BattleKind::Wild, true, true)); world.list = List::BattleParty; world.cursor_max = 2; world.grid = false; assert_eq!(run(&mut world, MacroKind::Switch).unwrap(), MacroAbort::Done); assert_eq!(world.cursor, 1); assert_eq!( world.pulses.iter().filter(|mask| **mask == buttons::A).count(), 2, "the party entry and then SWITCH, with no battle menu in between: {:?}", world.pulses ); } #[test] fn item_reaches_the_potion_by_reading_the_bags_cursor() { // The one script agent A's seam cannot yet support: `BattleMenu` has no bag variant, so the // bag list reports no cursor. The contract's rule wins over finishing the job — "navigate by // reading cursor state, never by counting presses" — and since section 14 the seam reports one // for the in-battle bag, so `ITEM` reaches the potion instead of waiting out `CURSOR_WAIT`. let mut world = World::battle(); world.bag = vec![(item::POKE_BALL, 3), (item::POTION, 2)]; world.opens.push_back(Opens { list: List::BattleBag, cursor: 0, max: 1, grid: false }); // And confirming the potion opens the party list, which is where the script says which // Pokémon to heal. Section 12.11: that step waits for the *party* list, so the fixture has to // open it -- the cartridge does. world.opens.push_back(Opens { list: List::BattleParty, cursor: 0, max: 2, grid: false }); assert_eq!(run(&mut world, MacroKind::Item).unwrap(), MacroAbort::Done); assert!( world.pulses.iter().filter(|mask| **mask == buttons::A).count() >= 2, "the bag was opened and the potion confirmed: {:?}", world.pulses ); } #[test] fn buy_potion_takes_buy_then_the_item_then_confirms_twice() { let mut world = World::room(); world.scene = Scene::Shop; world.list = List::Shop(ShopScreen::BuySellQuit); world.cursor_max = 2; world.stock = vec![item::POKE_BALL, item::POTION]; world.money = 1000; world.b_to_close = u8::MAX; // A on BUY opens the priced stock list. world.opens.push_back(Opens { list: List::Shop(ShopScreen::Buying), cursor: 0, max: 1, grid: false, }); assert_eq!(run(&mut world, MacroKind::BuyPotion).unwrap(), MacroAbort::Done); assert_eq!(world.cursor, 1, "the potion is the second thing the mart stocks"); assert_eq!( world.pulses.iter().filter(|mask| **mask == buttons::A).count(), 4, "BUY, the item, the quantity and the price: {:?}", world.pulses ); } #[test] fn buy_ball_skips_the_counter_menu_when_the_buy_list_is_already_up() { let mut world = World::room(); world.scene = Scene::Shop; world.list = List::Shop(ShopScreen::Buying); world.cursor_max = 1; world.stock = vec![item::POKE_BALL, item::POTION]; world.money = 1000; world.b_to_close = u8::MAX; assert_eq!(run(&mut world, MacroKind::BuyBall).unwrap(), MacroAbort::Done); assert_eq!(world.cursor, 0); assert_eq!( world.pulses.iter().filter(|mask| **mask == buttons::A).count(), 3, "the item, the quantity and the price: {:?}", world.pulses ); } #[test] fn leave_backs_out_of_a_pc() { let mut world = World::room(); world.scene = Scene::Pc; world.list = List::Pc; world.b_to_close = 2; assert_eq!(run(&mut world, MacroKind::Leave).unwrap(), MacroAbort::Done); assert_eq!(world.pulses, vec![buttons::B; 2]); assert_eq!(world.scene, Scene::Overworld); } #[test] fn a_cursor_that_cannot_reach_its_target_reports_blocked() { // A list whose cursor is pinned to one entry: the target is off the end of it. let mut world = World::battle(); world.mons.truncate(1); world.cursor_max = 0; assert_eq!(run(&mut world, MacroKind::Run).unwrap(), MacroAbort::Blocked); assert!(!world.pulses.contains(&buttons::A), "it never confirmed the wrong entry"); } #[test] fn running_names_the_macro_while_it_owns_the_buttons() { let mut world = World::ground_floor().at(3, 4); let (palette, slot) = pick(&mut world, MacroKind::GoOut); let mut machine = MacroMachine::new(1); assert_eq!(machine.running(), None); machine.start(&palette, slot, &mut world).expect("GO OUT starts"); assert_eq!(machine.running(), Some("GO OUT")); assert!(machine.outcome().is_none(), "nothing has finished yet"); let mut frames = 0; while let Some(mask) = machine.step(&mut world) { world.frame(mask); frames += 1; assert_eq!(machine.running(), Some("GO OUT")); assert_eq!(machine.frames(), frames); } assert!(frames > 0 && frames < FRAME_CAP, "{frames} frames"); assert_eq!(machine.running(), None, "the buttons go back between macros"); assert_eq!(machine.outcome(), Some(("GO OUT", MacroAbort::Done))); } #[test] fn cancel_gives_the_buttons_back_as_blocked() { let mut world = World::ground_floor().at(3, 4); let (palette, slot) = pick(&mut world, MacroKind::GoOut); let mut machine = MacroMachine::new(1); machine.start(&palette, slot, &mut world).expect("GO OUT starts"); machine.cancel(); assert_eq!(machine.running(), None); assert_eq!(machine.outcome(), Some(("GO OUT", MacroAbort::Blocked))); assert!(machine.step(&mut world).is_none()); } #[test] fn every_outcome_and_refusal_has_a_word_for_the_ticker() { for (outcome, label) in [ (MacroAbort::Done, "done"), (MacroAbort::Blocked, "blocked"), (MacroAbort::Timeout, "timeout"), (MacroAbort::Refused, "refused"), ] { assert_eq!(outcome.label(), label); } for reason in [ Refusal::Unbound, Refusal::WrongScene, Refusal::Precondition, Refusal::NoRoute, Refusal::Busy, ] { assert!(!reason.label().is_empty()); } } // --------------------------------------------------------------------------------------------- // Section 9: plan mode's policy // --------------------------------------------------------------------------------------------- /// The plan's entries in rank order, with `-` for a rank nothing reached. fn plan(world: &mut World) -> Vec<&'static str> { let scene = world.scene(); names(&plan::plan_for(scene, world)) } /// A person and an object on the ground floor, so both walking entries have something to aim at. /// /// The fly faces *away* from both of them, because `TALK` ranks first whenever it is already /// facing one (section 9.1, amended) and these fixtures are about the rest of the order. The /// facing case has its own test. fn populated() -> World { let mut world = World::ground_floor().wall(&[(3, 4)]); world.facing = Facing::Up; world.npcs = vec![ Npc { slot: 1, picture: 1, x: 4, y: 3, facing: Facing::Down }, Npc { slot: 2, picture: BALL_SPRITE, x: 3, y: 4, facing: Facing::Down }, ]; world } #[test] fn the_indoor_pad_is_section_nine_ones_set() { let mut world = populated(); assert_eq!( plan(&mut world), ["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"], "no objective is known, so `GO OBJECTIVE` is not on the pad" ); } #[test] fn the_outdoor_pad_has_the_route_and_no_passage_in_it() { let mut world = populated(); world.map = 0x00; assert_eq!(plan(&mut world), ["GO ROUTE", "GO ITEM", "GO NPC", "GO FRONTIER"]); } #[test] fn a_known_objective_takes_rank_zero() { // Standing on Red's ground floor with the next rung in Pallet Town: the doormats are written // `LAST_MAP`, and an outdoor objective is what makes them the objective's own door (section 9). let mut world = populated(); world.warps[1].destination_map = 0xff; world.warps[2].destination_map = 0xff; world.objective = Some(Objective { map: 0x00, tile: None, warp: None, edge: None, target: None }); assert_eq!(plan(&mut world)[0], "GO OBJECTIVE"); let goals: Vec = objective_goals(&mut world).into_iter().map(|aim| aim.tile).collect(); assert_eq!(goals, vec![Tile::new(2, 7), Tile::new(3, 7)], "the two doormats and not the stairs"); } #[test] fn a_rung_on_another_floor_of_this_building_is_reached_through_the_passage() { // Section 9.1 used to say "`GO WARP` ranks above `GO OUT` only when the objective is on // another floor of the same building". Section 14 leaves no ranking to state: both buttons are // on the pad, the fly chooses, and what the *objective* aims at is the passage -- which is the // half that was ever observable. let mut world = populated(); world.objective = Some(Objective { map: 0x26, tile: None, warp: None, edge: None, target: None }); assert!(plan::passage_to_objective(&mut world)); assert_eq!( plan(&mut world), ["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"] ); // And the objective's own goal is the staircase, because that is the warp that names it. let goals: Vec = objective_goals(&mut world).into_iter().map(|aim| aim.tile).collect(); assert_eq!(goals, vec![Tile::new(7, 1)]); } #[test] fn an_objective_on_this_map_with_no_finer_place_falls_through() { // Every rung the Pokémon catalog knows names a map and nothing finer, so standing on that map // is as close as `GO OBJECTIVE` can get: its button leaves the pad, which is exactly the // behaviour wanted in Oak's lab. let mut world = populated(); world.objective = Some(Objective { map: world.map, tile: None, warp: None, edge: None, target: None }); assert!(objective_goals(&mut world).is_empty()); // The way out is still on the pad, because section 14 leaves no ranking to demote it in. // What kept the fly from bouncing between Oak's lab and Pallet Town once per hold is the // *ledger* half of that fix (`ways`' tiers), and it is unchanged. assert_eq!( plan(&mut world), ["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"] ); // A rung the catalog does know a tile for is walked to instead. world.objective = Some(Objective { map: world.map, tile: Some(Tile::new(6, 3)), warp: None, edge: None, target: None }); assert_eq!( plan(&mut world), ["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"] ); let aims = objective_goals(&mut world); assert_eq!(aims.iter().map(|aim| (aim.tile, aim.press)).collect::>(), vec![ (Tile::new(6, 3), None) ]); // And it carries the key the blocked ledger would name it by, which is the tile itself. assert_eq!(aims[0].key, Some(TargetKey::Tile(Tile::new(6, 3)))); } #[test] fn a_thing_this_session_has_talked_to_leaves_the_plan() { // Section 12's talked ledger: a person this session has talked to stops being a reason to walk // anywhere, and when the map's people are all talked to `GO NPC` is not on the pad at all. // The test it replaces asked the exploration ledger whether the fly had stood on every tile // around the thing, which never closes for a villager standing in the open -- the town loop. let mut world = populated(); world.talked.insert(TalkTarget::Sprite(1)); assert_eq!( plan(&mut world), ["GO OUT", "GO WARP", "GO ITEM", "GO FRONTIER"], "the person drops out and the object stays" ); assert!(!precondition(MacroKind::GoNpc, &mut world), "and the button is gone"); // The object is the other half, keyed by its own sprite slot. world.talked.insert(TalkTarget::Sprite(2)); assert_eq!(plan(&mut world), ["GO OUT", "GO WARP", "GO FRONTIER"]); // Palette mode reads the same ledger: an unbound slot is dim rather than aimed at a villager // the fly has already heard out. let mut outdoors = populated(); outdoors.map = 0x00; outdoors.talked.insert(TalkTarget::Sprite(1)); assert!(!names(&Palette::for_scene(Scene::Overworld, &mut outdoors)).contains(&"GO NPC")); } #[test] fn the_battle_plan_attacks_first_and_switches_only_under_a_quarter() { // `World::battle`'s active Pokémon is on 4 of 20 HP, which is under a quarter, with a healthy // one on the bench: both of section 9's conditions hold. let mut world = World::battle(); assert!(plan::failing(&mut world)); let pad = plan(&mut world); assert!(pad.contains(&"MOVE 1") && pad.contains(&"SWITCH"), "{pad:?}"); // Healthy again: `ATTACK` alone, because nothing is wrong. let mut healthy = World::battle(); healthy.mons[0] = mon(0, 20, 20, &[(33, 30)]); assert!(!plan::failing(&mut healthy)); // `MOVE 1` is what keeps the own-turn pad from being empty when every other button drops out: // FIGHT is one of this menu's four entries and it always opens (12.8), where the `NEXT` that // used to carry the job merely reopened the list `BACK` had just closed (12.10). `ITEM` needs // a potion and low HP, so it is absent here; this Pokémon has one move. assert_eq!(plan(&mut healthy), ["MOVE 1", "SWITCH"]); } #[test] fn the_battle_plan_runs_from_a_wild_battle_only_when_the_whole_party_is_weak() { let mut world = World::battle(); assert!(!plan::party_weak(&mut world), "there is a healthy one on the bench"); assert!(!plan(&mut world).contains(&"RUN")); // Every Pokémon that can still fight is under a quarter: there is nothing to switch to and // the next hit ends the battle, which is the state section 9 puts `RUN` in. world.mons = vec![mon(0, 4, 20, &[(33, 30)]), mon(1, 3, 20, &[(33, 30)])]; assert!(plan::party_weak(&mut world)); assert_eq!(plan(&mut world), ["MOVE 1", "SWITCH", "RUN"]); // A trainer battle has no RUN at all, in the plan or in the palette. world.battle = Some((BattleKind::Trainer, true, false)); assert!(!plan(&mut world).contains(&"RUN")); } #[test] fn a_forced_switch_plans_one_entry_and_the_other_scenes_plan_their_one_move() { let mut world = World::battle(); world.scene = Scene::Battle { own_turn: false, forced_switch: true }; world.battle = Some((BattleKind::Wild, false, true)); world.list = List::BattleParty; world.cursor_max = 2; // `SWITCH` plus the press that advances a battle: a forced switch with nothing healthy to // switch to is a menu the game will not let the fly cancel, and the pad was empty there. assert_eq!(plan(&mut world), ["NEXT", "SWITCH"]); // Section 12's sets, one row each. `YES`/`NO` are on every dialog because the cartridge has no // "a choice is open" flag to gate them on; `BACK` is on `Unknown` because that is where the // Pokédex, the trainer card and OPTION land and A does not leave any of them; the mart's four // purchases stay unbound until the stock list is read. for (scene, entries) in [ (Scene::Dialog, vec!["NEXT", "YES", "NO"]), (Scene::Unknown, vec!["NEXT", "BACK"]), (Scene::Menu, vec!["CLOSE", "CONFIRM", "BACK"]), // Section 13: the shop's four purchases stay unbound until a counter's stock list is // read, so what is left is the two answers any list has. (Scene::Shop, vec!["CONFIRM", "LEAVE"]), (Scene::Pc, vec!["CONFIRM", "LEAVE"]), ] { let mut world = World::room(); world.scene = scene; world.box_open = scene == Scene::Unknown; assert_eq!(plan(&mut world), entries, "{}", scene.label()); } // The title screen has no plan, exactly as it has no palette (section 2). let mut title = World::room(); title.scene = Scene::Title; assert!(plan(&mut title).is_empty()); } #[test] fn the_plan_is_deterministic_and_offers_nothing_the_palette_would_leave_unbound() { let mut world = populated(); let once = plan(&mut world); let twice = plan(&mut world); assert_eq!(once, twice, "the same game state plans the same order"); for name in once.iter().filter(|name| **name != "-") { let kind = MacroKind::ALL .into_iter() .find(|kind| kind.name() == *name) .expect("a plan entry is a macro"); assert!(precondition(kind, &mut world), "{name} is in the plan with no precondition"); } } #[test] fn talk_is_on_the_pad_while_the_fly_faces_something_it_has_not_talked_to() { // Section 9.1, amended 2026-09-16, and section 14: `TALK` is on the overworld pad whenever the // fly is facing an untalked object or person and absent otherwise. It used to be "ranks // first"; with a slot per type there is no ranking left, only the precondition. let mut world = populated(); world.facing = Facing::Down; // the ball at (3, 4) is one step down from (3, 3) assert!(super::palette::facing_untalked(&mut world)); assert!(plan(&mut world).contains(&"TALK")); // A person is the other half of it, and a bare tile is neither. let mut person = populated(); person.facing = Facing::Right; // the villager at (4, 3) assert!(plan(&mut person).contains(&"TALK")); let mut nothing = populated(); nothing.facing = Facing::Left; // (2, 3) has nothing on it assert!(!super::palette::facing_untalked(&mut nothing)); assert!(!plan(&mut nothing).contains(&"TALK"), "an empty tile is not a reason to press A"); // A sign is the third table it reads, and a sign in a wall is the case a rule about the // *thing's* other sides could never have handled. let mut sign = World::room().at(3, 3); sign.facing = Facing::Down; sign.signs = vec![Sign { x: 3, y: 4, text_id: 1 }]; sign.walls.insert(Tile::new(3, 4)); assert!(super::palette::facing_untalked(&mut sign)); assert!(plan(&mut sign).contains(&"TALK")); // And what stops the press repeating is the ledger: a thing this session has talked to is // neither faced nor walked to again. let mut touched = populated(); touched.facing = Facing::Down; touched.talked.insert(TalkTarget::Sprite(2)); assert!(!super::palette::facing_untalked(&mut touched), "the ball has been talked to"); assert!(!plan(&mut touched).contains(&"TALK")); assert!(!plan(&mut touched).contains(&"GO ITEM")); } #[test] fn the_two_dealers_are_one_table() { // Section 14: `plan_for` *is* `Palette::for_scene`. Two dealers that could disagree about // which buttons a scene has -- and did, for two releases, over `GO OBJECTIVE` -- are one, and // a slot is a type index in both. let mut world = populated(); world.facing = Facing::Down; assert_eq!( names(&Palette::for_scene(Scene::Overworld, &mut world)), plan(&mut world) ); assert!(plan(&mut world).contains(&"TALK")); assert_eq!( Palette::for_scene(Scene::Overworld, &mut world) .slot(MacroId(MacroKind::Talk.slot())) .map(|spec| spec.name), Some("TALK") ); } // --------------------------------------------------------------------------------------------- // Section 12: the blocked- and reached-target ledgers (Viridian City, 2026-09-16) // --------------------------------------------------------------------------------------------- /// Two objects on the floor, neither next to the fly, in a room where no step ever lands. /// /// `glue` is what makes a walk fail the way the cartridge failed in Viridian City: the route /// search finds a way, the macro starts, and three tiles in a row leave the player where it was, /// which is [`MacroAbort::Blocked`]. The two objects are at different distances so that "the /// macro picks the next candidate" is observable rather than inferred. fn two_unreachable_objects() -> World { let mut world = World::room(); world.facing = Facing::Up; world.npcs = vec![ Npc { slot: 2, picture: BALL_SPRITE, x: 1, y: 1, facing: Facing::Down }, Npc { slot: 3, picture: BALL_SPRITE, x: 6, y: 6, facing: Facing::Down }, ]; world.glue = true; world } /// Whether `kind`'s button is on the pad in the scene the world is in. fn on_the_pad(world: &mut World, kind: MacroKind) -> bool { let scene = world.scene(); plan::plan_for(scene, world).slots.iter().flatten().any(|spec| spec.kind == kind) } #[test] fn a_blocked_target_leaves_the_choice_for_ten_brain_minutes_and_the_button_leaves_the_pad() { // The live stall: `GO ITEM start, GO ITEM blocked`, once per hold for forty-five minutes, // because "nearest untalked" re-chose the same unreachable object every time. The fix is a // ledger of what a walk has just failed at, read where the target is chosen. let mut world = two_unreachable_objects(); assert!(on_the_pad(&mut world, MacroKind::GoItem)); assert_eq!( untalked_objects(&mut world).len(), 2, "both objects are candidates before anything has failed" ); // The first walk aims at the nearer ball and blocks. That excludes *that* ball, and the macro // is left with the other one -- which is the whole difference from the live behaviour. assert_eq!(run(&mut world, MacroKind::GoItem), Ok(MacroAbort::Blocked)); assert_eq!( untalked_objects(&mut world), vec![(Tile::new(6, 6), TalkTarget::Sprite(3))], "the ball the walk failed at is out of the choice; the other one is not" ); assert!(on_the_pad(&mut world, MacroKind::GoItem), "there is still something to walk to"); // The second blocks too, and now there is no candidate left: the button leaves the pad rather // than sitting on it and blocking once per hold. assert_eq!(run(&mut world, MacroKind::GoItem), Ok(MacroAbort::Blocked)); assert!(untalked_objects(&mut world).is_empty()); assert!(!on_the_pad(&mut world, MacroKind::GoItem), "no candidate, no button"); assert_eq!(world.targets.len(), (2, 0), "two exclusions standing, nothing reached"); // A window, not a life sentence: unreachable is a fact about where the fly was standing, so // ten brain minutes later both balls are candidates again. world.targets.clock(BLOCKED_MINUTES_DEFAULT * 60_000.0 - 1.0); assert!(!on_the_pad(&mut world, MacroKind::GoItem), "still inside the window"); world.targets.clock(BLOCKED_MINUTES_DEFAULT * 60_000.0); assert_eq!(untalked_objects(&mut world).len(), 2, "the window has closed on both"); assert!(on_the_pad(&mut world, MacroKind::GoItem)); assert_eq!(world.targets.len(), (0, 0)); } #[test] fn a_timeout_costs_a_closing_walk_a_strike_and_a_stalled_one_its_target() { // The Viridian loop's first cause (2026-09-17, `infra/docs/macros-traps.md`). The frame cap is // ten seconds and a town is wider than ten seconds of walking, so a walk that spends the cap // getting *closer* has not failed at its target -- it is half way there. Excluding it anyway is // what took the Route 2 connection off `GO ROUTE`'s list for ten brain minutes at a time, and // `GO OBJECTIVE` with it, because both aim at the same exit key. // // The cap that ends such a walk is [`WALK_FRAME_CEILING`] since 2026-09-17 — a minute of brain // time, not ten seconds — so the map here is one no minute of walking crosses. let mut world = World::room(); world.size = MapSize { width: 254, height: 254 }; world.facing = Facing::Up; world.npcs = vec![Npc { slot: 2, picture: BALL_SPRITE, x: 250, y: 250, facing: Facing::Down }]; let ball = TargetKey::Thing(TalkTarget::Sprite(2)); let began = world.player.distance(Tile::new(250, 250)); let mut machine = MacroMachine::new(0x1234_5678); assert!(on_the_pad(&mut world, MacroKind::GoItem)); assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoItem), Ok(MacroAbort::Timeout)); assert!(!world.targets.blocked(world.map, ball), "a closing walk keeps its target"); assert_eq!(world.targets.strikes(world.map, ball), 1, "it costs one strike"); // So the next hold resumes it, from closer than it started. assert!(on_the_pad(&mut world, MacroKind::GoItem)); assert!(world.player.distance(Tile::new(250, 250)) < began); // But not for ever: three ceilings is three brain minutes of walking at one thing, and a target // still not reached by then is one to leave alone for a while. assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoItem), Ok(MacroAbort::Timeout)); assert!(!world.targets.blocked(world.map, ball)); assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoItem), Ok(MacroAbort::Timeout)); assert!(world.targets.blocked(world.map, ball), "the third cap excludes it"); assert_eq!(world.targets.strikes(world.map, ball), 0, "and the exclusion supersedes the count"); assert!(!on_the_pad(&mut world, MacroKind::GoItem)); // A walk that spends the cap without ever getting nearer is a different fact, and it excludes // the target at once: ten seconds of buttons and no ground gained is about the target. let mut away = World::room(); away.size = MapSize { width: 64, height: 64 }; away.facing = Facing::Up; away.npcs = vec![Npc { slot: 2, picture: BALL_SPRITE, x: 60, y: 60, facing: Facing::Down }]; away.glue = true; // Glued in place, the three-failure rule fires first -- which is the other abort, and it // excludes the target as it always has. assert_eq!(run(&mut away, MacroKind::GoItem), Ok(MacroAbort::Blocked)); assert!(away.targets.blocked(away.map, ball)); assert_eq!(away.targets.strikes(away.map, ball), 0, "a block is not a strike"); assert!(!on_the_pad(&mut away, MacroKind::GoItem)); } #[test] fn a_reached_person_is_skipped_for_the_window() { // The other half of the stall: `GO NPC start, GO NPC done`, over and over, because only a // completed `TALK` marked a person talked and the fly is free never to press A. Arriving and // facing is all `GO NPC` promises, so arriving and facing retires the target. let mut world = World::room(); // Pallet Town: an outdoor map, which is where `GO NPC` is one of the six channels. world.map = 0x00; world.facing = Facing::Up; world.npcs = vec![Npc { slot: 1, picture: 1, x: 4, y: 3, facing: Facing::Down }]; assert!(on_the_pad(&mut world, MacroKind::GoNpc)); assert_eq!(run(&mut world, MacroKind::GoNpc), Ok(MacroAbort::Done)); assert!( world.targets.reached(world.map, TargetKey::Thing(TalkTarget::Sprite(1))), "arrived and facing is what the reached ledger records" ); assert!(untalked_people(&mut world).is_empty()); assert!(!on_the_pad(&mut world, MacroKind::GoNpc), "the map's only person is accounted for"); // But a window, not a retirement: **arriving is not talking** // (`docs/design/macros.md` section 12.4). 12.1 retired a reached target for the session, which // also retired every person the fly walked to and then wandered off from without pressing A -- // and nothing could bring it back. Oak is the case that found it: the fly stood in front of // him, chose something else, and the parcel stayed undelivered for ever. An hour later he is // on offer again. world.targets.clock(60.0 * 60_000.0); assert_eq!(world.targets.len(), (0, 0), "the window has closed"); // From a tile that is not in front of him, which is the state `TALK` owns (row 4). world.player = Tile::new(4, 1); assert!(on_the_pad(&mut world, MacroKind::GoNpc), "so the conversation is available again"); } #[test] fn a_finished_talk_still_excludes_the_thing_it_talked_to() { // The ledger section 9.2 added is untouched: a completed `TALK` writes it, and a thing that // has been talked to is neither walked to nor faced again -- which is what makes the reached // ledger an addition rather than a replacement. let mut world = World::room(); world.map = 0x00; world.facing = Facing::Right; world.npcs = vec![Npc { slot: 1, picture: 1, x: 4, y: 3, facing: Facing::Left }]; assert!(on_the_pad(&mut world, MacroKind::Talk)); assert_eq!(run(&mut world, MacroKind::Talk), Ok(MacroAbort::Done)); assert!(world.talked.contains(&TalkTarget::Sprite(1)), "the talked ledger, not the reached"); assert_eq!(world.targets.len(), (0, 0), "a TALK reaches nothing: it is already standing there"); assert!(!on_the_pad(&mut world, MacroKind::Talk)); assert!(!on_the_pad(&mut world, MacroKind::GoNpc)); } #[test] fn a_blocked_way_out_leaves_the_pad_rather_than_being_aimed_at_again() { // The same ledger over `GO OUT`, `GO WARP` and `GO ROUTE`, whose key is the exit rather // than the thing. Red's ground floor has two doormats, so the first failure leaves one. let mut world = World::ground_floor(); world.glue = true; assert!(on_the_pad(&mut world, MacroKind::GoOut)); assert_eq!(ways(&mut world, Way::Exit).len(), 2, "two doormats"); assert_eq!(run(&mut world, MacroKind::GoOut), Ok(MacroAbort::Blocked)); assert_eq!(ways(&mut world, Way::Exit).len(), 1, "the mat the walk failed at is excluded"); assert!(on_the_pad(&mut world, MacroKind::GoOut)); assert_eq!(run(&mut world, MacroKind::GoOut), Ok(MacroAbort::Blocked)); assert!(ways(&mut world, Way::Exit).is_empty()); assert!(!on_the_pad(&mut world, MacroKind::GoOut), "no way out left to aim at"); // The staircase is a different key and a different macro: excluding the mats says nothing // about it. assert_eq!(ways(&mut world, Way::Passage).len(), 1); assert!(on_the_pad(&mut world, MacroKind::GoWarp)); } #[test] fn a_frontier_tile_stood_on_stops_being_one_and_a_blocked_one_is_excluded() { // Section 12's second ledger needs nothing for `GO FRONTIER`'s own arrival, and this is the // verification of that claim rather than an assumption: the tile the macro steps onto enters // the adapter's exploration ledger, and `path::frontier` already asks it. Only the *blocked* // half is new here. let mut world = World::room(); for y in 0..8 { for x in 0..8 { world.stood.insert(Tile::new(x, y)); } } world.stood.remove(&Tile::new(0, 0)); assert_eq!( super::palette::frontier_aims(&mut world), vec![(Tile::new(0, 1), Facing::Up), (Tile::new(1, 0), Facing::Left)], "the two tiles bordering the one square of new ground" ); // Standing on it is what retires it, and nothing else has to record anything. world.stood.insert(Tile::new(0, 0)); assert!(super::palette::frontier_aims(&mut world).is_empty()); assert!(!on_the_pad(&mut world, MacroKind::GoFrontier)); // And a frontier the walk cannot get to is excluded by the blocked ledger, one tile at a // time, until the button leaves the pad. world.stood.remove(&Tile::new(0, 0)); world.glue = true; assert_eq!(run(&mut world, MacroKind::GoFrontier), Ok(MacroAbort::Blocked)); assert_eq!(super::palette::frontier_aims(&mut world).len(), 1); assert_eq!(run(&mut world, MacroKind::GoFrontier), Ok(MacroAbort::Blocked)); // Both *bordering* tiles are excluded now, so the windowed answer is empty -- and section // 13.1's fallback offers the unstood tile itself, which is the one thing left on this map // worth walking to. One more blocked walk excludes that too and the button leaves the pad, so // the loop is still bounded at one walk per target per window. assert_eq!( super::palette::frontier_aims(&mut world), vec![(Tile::new(0, 0), Facing::Down)], "the nearest unstood tile on the whole map" ); assert_eq!(run(&mut world, MacroKind::GoFrontier), Ok(MacroAbort::Blocked)); assert!(super::palette::frontier_aims(&mut world).is_empty()); assert!(!on_the_pad(&mut world, MacroKind::GoFrontier)); } #[test] fn the_exclusion_window_is_ten_brain_minutes_by_default() { // The operator's number, and the one figure of the pair that is a judgement call // (`FLY_MACRO_BLOCKED_MINUTES` overrides it in the session's ledger). assert_eq!(BLOCKED_MINUTES_DEFAULT, 10.0); let mut ledger = Targets::default(); assert_eq!(ledger.minutes(), 10.0); assert!(ledger.is_empty()); let target = TargetKey::Exit(ExitId::Warp(1)); ledger.clock(1_000.0); ledger.record_blocked(0x22, target); assert!(ledger.blocked(0x22, target)); assert!(!ledger.blocked(0x23, target), "the same warp index on another map is another target"); assert!(!ledger.reached(0x22, target), "blocked is not reached"); ledger.clock(1_000.0 + 10.0 * 60_000.0 - 1.0); assert!(ledger.blocked(0x22, target)); ledger.clock(1_000.0 + 10.0 * 60_000.0); assert!(!ledger.blocked(0x22, target), "the window has closed"); assert!(ledger.is_empty()); // A second failure restarts the window rather than keeping the first instant. ledger.record_blocked(0x22, target); assert!(ledger.blocked(0x22, target)); assert_eq!(ledger.len(), (1, 0)); } // --------------------------------------------------------------------------------------------- // The Viridian loop of 2026-09-17 and the traps the audit beside it found // (`infra/docs/macros-traps.md`). // --------------------------------------------------------------------------------------------- #[test] fn a_thing_the_fly_is_already_facing_is_talks_and_not_a_walks() { // `GO NPC start, GO NPC done (150 ms)`, once per hold. Standing beside the girl and looking at // her is everything `GO NPC` promises, so the walk finishes in twenty frames having moved // nothing; the press that belongs on the pad there is `TALK`. let mut world = World::room(); world.map = 0x00; world.player = Tile::new(3, 3); world.facing = Facing::Right; world.npcs = vec![Npc { slot: 1, picture: 1, x: 4, y: 3, facing: Facing::Left }]; assert!(untalked_people(&mut world).is_empty(), "the person ahead is not a walk"); assert!(!on_the_pad(&mut world, MacroKind::GoNpc)); assert!(on_the_pad(&mut world, MacroKind::Talk), "the press is what is offered instead"); // Turn away and she is a walk again: nothing is retired here, and a fly that never presses A // has not lost the person. world.facing = Facing::Left; assert_eq!(untalked_people(&mut world).len(), 1); assert!(on_the_pad(&mut world, MacroKind::GoNpc)); assert!(!on_the_pad(&mut world, MacroKind::Talk)); } #[test] fn a_front_door_nobody_can_name_is_not_somewhere_new() { // The other half of the warp bounce. A house with no row in the geography table has a front // door whose destination is `LAST_MAP`, and treating "nobody can name it" as "somewhere new" // made `GO OUT` permanently first-tier fresh -- so the fly, standing on the doormat it had just // walked in over, left again inside ten frames, every hold. let mut world = World::ground_floor(); world.map = 0x2b; // An interior with no row in `macros::geography`. world.warps = vec![ Warp { x: 7, y: 1, destination_warp: 2, destination_map: 0x26 }, Warp { x: 2, y: 7, destination_warp: 1, destination_map: 0xff }, ]; let fresh: Vec = ways(&mut world, Way::Exit); assert_eq!(fresh.len(), 1, "the door is still a candidate: a room has to be leavable"); // By the last-resort tier rather than as somewhere new, which is what the staircase beside it // demonstrates: a passage into an unvisited interior *is* fresh, and stops being offered once // the floor above has been seen. assert_eq!(ways(&mut world, Way::Passage).len(), 1); world.seen_maps.insert(0x26); assert!(ways(&mut world, Way::Passage).is_empty()); } #[test] fn a_frontier_tile_whose_new_ground_cannot_be_stood_on_is_excluded() { // `GO FRONTIER done (420 ms)`, over and over, on the same tile. The arrival press faces the new // ground, and the contract calls that `Done` whether or not the player moved -- so a ledge, a // tile-pair rule or a person on the far side leaves the tile a frontier for ever, because the // exploration ledger only ever records ground somebody stood on. let mut world = World::room(); for y in 0..8 { for x in 0..8 { world.stood.insert(Tile::new(x, y)); } } world.stood.remove(&Tile::new(4, 3)); world.player = Tile::new(3, 3); // The fly is standing on a tile that borders the one square of new ground, so the walk has no // tiles to walk and goes straight to its arrival. assert!(super::palette::frontier_aims(&mut world).contains(&(Tile::new(3, 3), Facing::Right))); world.glue = true; assert_eq!(run(&mut world, MacroKind::GoFrontier), Ok(MacroAbort::Done), "it faced it"); assert!( world.targets.blocked(world.map, TargetKey::Tile(Tile::new(3, 3))), "and the tile it could not stand on is excluded for the window" ); assert!(!super::palette::frontier_aims(&mut world).contains(&(Tile::new(3, 3), Facing::Right))); } #[test] fn a_tile_a_sprite_is_standing_on_is_not_new_ground() { // Row 32 of `infra/docs/macros-traps.md`. The collision table knows nothing about people or // objects -- that is what the executor's per-step "did the player move" check is for -- so a // villager standing in the open reads walkable underneath. The fly can never stand on that // tile while the villager is there, so it can never enter the visited ledger either: it is a // frontier for ever, and the arrival press faces it, reports `Done` and changes nothing. The // blocked ledger bounds that at one walk per window (row 5); leaving the tile out of the list // is what lets the frontier *exhaust*, which is what the button leaving the pad needs. let mut world = World::room(); for y in 0..8 { for x in 0..8 { world.stood.insert(Tile::new(x, y)); } } world.stood.remove(&Tile::new(4, 3)); world.player = Tile::new(3, 3); assert!( super::palette::frontier_aims(&mut world).contains(&(Tile::new(3, 3), Facing::Right)), "one square of new ground, and the tile the fly is standing on borders it" ); assert!(precondition(MacroKind::GoFrontier, &mut world)); // A person walks onto the one square of new ground. world.npcs = vec![Npc { slot: 1, picture: 0x0d, x: 4, y: 3, facing: Facing::Down }]; assert!( super::palette::frontier_aims(&mut world).is_empty(), "the ground under a person is not ground: {:?}", super::palette::frontier_aims(&mut world) ); assert!( !precondition(MacroKind::GoFrontier, &mut world), "and a map with no frontier leaves the button off the pad" ); // The same for the tile to *stand on*: a sprite on it is not somewhere to walk to. world.npcs = vec![Npc { slot: 1, picture: 0x0d, x: 3, y: 3, facing: Facing::Down }]; world.player = Tile::new(6, 6); let aims = super::palette::frontier_aims(&mut world); assert!(!aims.contains(&(Tile::new(3, 3), Facing::Right)), "{aims:?}"); assert!(aims.contains(&(Tile::new(4, 2), Facing::Down)), "the other three sides remain: {aims:?}"); } #[test] fn a_door_into_the_objectives_own_map_is_toward_it_with_no_route_in_the_graph() { // Tier 2 of `ways`, for a map the geography table has no row for. Every destination visited, // so nothing is fresh; `geography::next_hop` answers `None`, because an unknown map has no // known neighbours; and the door that plainly leads to the objective's map is still the one // toward it. Without this a way out whose whole destination list is visited had no candidate // of any tier on an outdoor map and the button left the pad (`GO ROUTE` has no tier 3). let mut world = World::room(); // An outdoor map id the table has no row for, so `Way::Route` is what its warps classify as. world.map = 0x0b; world.warps = vec![ Warp { x: 2, y: 7, destination_warp: 0, destination_map: 0x05 }, Warp { x: 5, y: 7, destination_warp: 0, destination_map: 0x06 }, ]; world.seen_maps.extend([0x05, 0x06]); world.objective = Some(Objective { map: 0x05, tile: None, warp: None, edge: None, target: None, }); let toward = ways(&mut world, Way::Route); assert_eq!( toward.iter().map(|exit| exit.id).collect::>(), vec![ExitId::Warp(0)], "the door into the objective's map, and only that one: {toward:?}" ); // And with no objective at all there is no tier 2 and no tier 3 for a route. world.objective = None; assert!(ways(&mut world, Way::Route).is_empty()); } #[test] fn a_no_route_refusal_records_what_it_could_not_reach() { // The refusal that recorded nothing: the palette's preconditions are the cheap question ("is // there an object on this map") and the route search is the real one, so a bound macro could // refuse `no route` once per hold for ever while its candidate list never changed. let mut world = World::room().wall(&[(2, 3), (4, 3), (3, 2), (3, 4)]); world.map = 0x00; world.facing = Facing::Up; world.npcs = vec![Npc { slot: 2, picture: BALL_SPRITE, x: 6, y: 6, facing: Facing::Down }]; assert!(on_the_pad(&mut world, MacroKind::GoItem), "the map has an object"); let slot = pick(&mut world, MacroKind::GoItem).1; assert_eq!( run(&mut world, MacroKind::GoItem), Err(MacroRefused { slot, reason: Refusal::NoRoute }) ); assert!( world.targets.blocked(world.map, TargetKey::Thing(TalkTarget::Sprite(2))), "the thing the sealed-in fly could not reach is excluded" ); assert!(!on_the_pad(&mut world, MacroKind::GoItem), "so the button leaves the pad"); } #[test] fn the_overworld_plan_never_truncates_the_frontier_away() { // Eleven buttons and six rows: the entry that used to be cut was the last one, which is the // fallback that always has somewhere to go while any ground is unexplored. `MENU` was the // other, and section 12.11 took it off the row rather than rescuing it again. let mut world = populated(); world.objective = Some(Objective { map: 0x00, tile: None, warp: None, edge: None, target: None }); world.signs = vec![Sign { x: 6, y: 6, text_id: 3 }]; let rows = plan(&mut world); assert!(rows.len() >= 6, "a full overworld pad is more than five buttons: {rows:?}"); assert_eq!( rows.last(), Some(&"GO FRONTIER"), "the explorer is dealt last and is never cut: {rows:?}" ); } /// Section 12.11: `MENU` is on no scene's pad, in any state of any scene. /// /// **What was live** (2026-09-22, rung 10, thirty minutes inside the Pewter museum's upper floor): /// macro starts `MENU` 82, `BACK` 82, `GO FRONTIER` 8, the event log alternating `MENU /// start/done, BACK start/done`. `MENU` pressed START, the start menu opened, its pad is `CLOSE`, /// `CONFIRM` and `BACK`, and `BACK` pressed B and closed it again -- two buttons that undo each /// other with nothing else changing, which is section 12.10's rule one scene wider than a battle. /// /// `MENU` was there as the overworld's *unconditional* button, the one that made an empty pad /// impossible. It is also section 12.2's trap by definition -- a precondition satisfied wherever /// the fly stands, and a macro that completes without moving -- and nothing in the vocabulary uses /// the start menu for anything, so there is nothing behind it worth pressing it for. What keeps the /// pad from being empty instead is the never-empty way out of [`ways`](super::palette::ways). #[test] fn menu_is_on_no_scenes_pad() { let mut world = populated(); for scene in [ Scene::Overworld, Scene::Dialog, Scene::Unknown, Scene::Menu, Scene::Shop, Scene::Pc, Scene::Title, Scene::Battle { own_turn: true, forced_switch: false }, Scene::Battle { own_turn: false, forced_switch: false }, Scene::Battle { own_turn: false, forced_switch: true }, ] { world.scene = scene; world.battle = matches!(scene, Scene::Battle { .. }) .then_some((BattleKind::Wild, true, false)); assert!( !super::palette::scene_set(scene, &mut world).contains(&MacroKind::Menu), "{} deals MENU", scene.label() ); assert!(!plan(&mut world).contains(&"MENU"), "{} binds MENU", scene.label()); } // The start menu is still reachable and still has its own pad: the fly's raw START reaches the // cartridge in macros mode (section 13.1), and what is on that pad is what leaves it. world.scene = Scene::Menu; world.list = List::Start; assert_eq!(pad_of(&mut world), ["CLOSE", "CONFIRM", "BACK"]); } #[test] fn no_playable_scene_deals_an_empty_pad() { // The battle-text deadlock of v0.2.4 was one scene with nothing on the pad. This is the sweep // for the rest of them, in each scene's *worst* state: nothing to attack with, nobody to switch // to, no potion, a trainer battle, no money and an empty bag. let mut worst = World::battle(); worst.battle = Some((BattleKind::Trainer, true, false)); worst.mons = vec![mon(0, 4, 20, &[(33, 0)])]; worst.bag = Vec::new(); worst.money = 0; assert!(!move_slot_bound(&mut worst, MacroKind::Move2), "no second move"); assert!(healthiest_other(&mut worst).is_none()); assert!(plan::plan_for(worst.scene(), &mut worst).bound() > 0, "own turn, nothing to do"); let mut forced = World::battle(); forced.scene = Scene::Battle { own_turn: false, forced_switch: true }; forced.battle = Some((BattleKind::Wild, false, true)); forced.mons = vec![mon(0, 4, 20, &[(33, 30)])]; assert!(healthiest_other(&mut forced).is_none(), "nothing healthy to switch to"); assert!(plan::plan_for(forced.scene(), &mut forced).bound() > 0); for scene in [ Scene::Dialog, Scene::Unknown, Scene::Menu, Scene::Shop, Scene::Pc, Scene::Battle { own_turn: false, forced_switch: false }, ] { let mut world = World::room(); world.scene = scene; // `Unknown` is dealt on what is drawn (section 12.13): a screen with words on it has // `NEXT` and `BACK`, and a scripted overworld frame is the one deliberate empty pad, // which `an_unknown_frame_with_no_box_on_it_deals_nothing` is about. world.box_open = scene == Scene::Unknown; world.battle = matches!(scene, Scene::Battle { .. }) .then_some((BattleKind::Wild, false, false)); assert!( plan::plan_for(scene, &mut world).bound() > 0, "{} deals nothing at all", scene.label() ); } // The title screen is the one exception, and it is not a trap: the scene is not playable, so // the sim loop hands the cartridge the fly's raw buttons there (`flysim::macros::decide`). let mut title = World::room(); title.scene = Scene::Title; assert_eq!(plan::plan_for(Scene::Title, &mut title).bound(), 0); } #[test] fn the_badges_rung_resolves_to_the_person_standing_in_the_gym() { // Rung 11 is BOULDER BADGE and `docs/design/ladder.md` gives its place as a *person* in the // Pewter gym (12.5's `PlaceKind`). What the rung-10 loop needed was the two halves of that // working together: the road into map `0x36` (`geography`, the museum rows and the gym's // own), and, once inside, the objective naming somebody to walk to rather than a map to be // on. This is the second half, on the map the rung is earned on. let mut world = World::room(); world.map = maps::PEWTER_GYM; // Both of Pewter's errands discharged. Section 13 puts an unvisited mart or centre *ahead* // of the rung's place, and the gym is inside Pewter's area like everything else in the town, // so until they are paid the objective is a building and not the leader. That is the errand // working, and it is why the ROM run below spends its first minutes in the town's shops. world.areas.insert((Amenity::Mart, maps::PEWTER_CITY)); world.areas.insert((Amenity::Center, maps::PEWTER_CITY)); world.npcs = vec![Npc { slot: 1, picture: 0x05, x: 4, y: 2, facing: Facing::Down }]; world.objective = Some(Objective { map: maps::PEWTER_GYM, tile: None, warp: None, edge: None, target: Some(PlaceKind::Person), }); let targets = super::palette::objective_targets(&mut world); assert_eq!(targets.len(), 1, "the person the rung names: {targets:?}"); assert_eq!(targets[0].0, Tile::new(4, 2)); // And the walk aims at standing beside them and turning to face them, which is `GO NPC`'s own // arrival and all `GO OBJECTIVE` ever promises: the press is `TALK`'s and the fly's. let goals: Vec = objective_goals(&mut world).into_iter().map(|aim| aim.tile).collect(); assert!(goals.contains(&Tile::new(4, 3)), "a tile beside them: {goals:?}"); assert!(goals.iter().all(|tile| tile.distance(Tile::new(4, 2)) == 1), "{goals:?}"); // The room the rung is in is not left while the thing that earns it is standing in it (12.5). assert!(ways(&mut world, Way::Exit).is_empty(), "the gym's door is not a candidate yet"); // Talked to, and the objective has nothing left here: the button leaves the pad and the ways // out come back, which is what carries the run on to the next rung. world.talked.insert(targets[0].1); assert!(super::palette::objective_targets(&mut world).is_empty()); assert!(objective_goals(&mut world).is_empty()); } #[test] fn a_frontier_no_walk_can_reach_takes_go_frontier_off_the_pad_and_keeps_it_off() { // Section 12.14, the rung-10 museum. The sealed pocket below is map `0x34` in miniature: the // unstood ground is real and it is fenced off, so `GO FRONTIER` refuses `no route` and writes // every tile it could not reach to the blocked ledger -- which is a *window*. Before this the // window lapsed after ten brain minutes and all of it was a candidate again: 1,235 starts in // 47 minutes over two museum floors and a town. The mark has no window. let mut world = World::room(); for y in 0..8 { for x in 0..8 { world.stood.insert(Tile::new(x, y)); } } world.stood.remove(&Tile::new(7, 7)); world.walls.insert(Tile::new(6, 7)); world.walls.insert(Tile::new(6, 6)); world.walls.insert(Tile::new(7, 5)); world.player = Tile::new(0, 0); assert!(!super::palette::frontier_aims(&mut world).is_empty(), "the pocket is a frontier"); assert!(precondition(MacroKind::GoFrontier, &mut world), "so the button is on the pad"); let refused = run(&mut world, MacroKind::GoFrontier).expect_err("the pocket is sealed"); assert_eq!(refused.reason, Refusal::NoRoute); assert!(world.exhausted.contains(&world.map), "the map is marked: {:?}", world.exhausted); assert!(super::palette::frontier_aims(&mut world).is_empty(), "nothing left to aim at"); assert!(!precondition(MacroKind::GoFrontier, &mut world), "and the button is off the pad"); // Ten brain minutes later the blocked window has lapsed and every tile of the pocket is a // candidate again -- and the button is still off the pad, because the ground has not moved. world.targets.clock(11.0 * 60_000.0); assert!(!world.targets.blocked(world.map, TargetKey::Tile(Tile::new(7, 6)))); assert!( super::palette::frontier_aims(&mut world).is_empty(), "the mark is not a window" ); // A map the fly walks to instead is untouched: the mark is one map's. world.map = 0x35; assert!(!super::palette::frontier_aims(&mut world).is_empty(), "another map is its own"); } #[test] fn a_frontier_mark_is_the_stood_ledgers_to_clear() { // The two halves of the rule as the types have them: the stood ledger answers "this is // ground the run had not stood on", which is the only event that can change which tiles the // fly can reach, and that answer is what clears the map's mark. let mut stood = super::cartridge::Stood::default(); assert!(stood.record(2, Tile::new(4, 4)), "the first time is new ground"); assert!(!stood.record(2, Tile::new(4, 4)), "the second time is not"); assert!(stood.record(0x34, Tile::new(4, 4)), "and a tile is a tile of one map"); use super::cartridge::FrontierLedger; let mut frontiers = super::cartridge::Frontiers::default(); assert!(!frontiers.frontier_exhausted(0x34)); frontiers.record(0x34); frontiers.record(0x34); assert!(frontiers.frontier_exhausted(0x34), "idempotent"); assert!(!frontiers.frontier_exhausted(0x35), "and one map's"); assert_eq!(frontiers.len(), 1); frontiers.clear(0x34); assert!(!frontiers.frontier_exhausted(0x34)); assert!(frontiers.is_empty()); } #[test] fn a_walk_that_could_only_get_closer_now_excludes_what_it_cannot_reach() { // Two readings of the same pocket, in the order they were measured // (`infra/docs/macros-traps.md`). `GO FRONTIER`'s goals are one key each, so "the key every // goal shares" answers `None` for it, and a closest-approach route -- the search's answer for a // goal it cannot reach -- carried no target at all: the macro spent the whole frame cap // wandering, recorded nothing, and was chosen again on the next hold for ever. Naming the // nearest goal fixed the recording and left the wandering, which became the 2026-09-17 stall: // a closest-approach walk that trades two tiles for a whole cap. A goal the search cannot reach // is now excluded at `start` instead of walked toward, and the key it is excluded under is // still the one the walk set out for. let mut world = World::room(); for y in 0..8 { for x in 0..8 { world.stood.insert(Tile::new(x, y)); } } // One square of new ground in a sealed pocket: the tile to stand on is walkable and the new // ground beside it is walkable, so it is a frontier, and neither can be reached from the room. world.stood.remove(&Tile::new(7, 7)); world.walls.insert(Tile::new(6, 7)); world.walls.insert(Tile::new(6, 6)); world.walls.insert(Tile::new(7, 5)); world.player = Tile::new(0, 0); let aims = super::palette::frontier_aims(&mut world); assert!(!aims.is_empty(), "the tiles beside the new ground are frontier: {aims:?}"); let refused = run(&mut world, MacroKind::GoFrontier).expect_err("the pocket is sealed"); assert_eq!(refused.reason, Refusal::NoRoute); assert!(world.pulses.is_empty(), "nothing was pressed: {:?}", world.pulses); // The ledger moved, so the next hold cannot be the same walk. assert_ne!( (world.targets.len(), super::palette::frontier_aims(&mut world).len()), ((0, 0), aims.len()), "a walk that got nowhere left a mark on the ledger" ); } // --------------------------------------------------------------------------------------------- // Section 12.4: the gate // --------------------------------------------------------------------------------------------- #[test] fn a_conversation_the_game_ends_by_moving_the_fly_is_not_talked_to() { // The Viridian gate (2026-09-17, `infra/docs/macros-traps.md` row 28). A still sprite in the // road north, a text box reading "This is private property!", and a script that takes the // joypad and walks the fly back one tile. `TALK` finished, the ledger was written, and the one // conversation that might have changed something was off the pad for the rest of the session. let mut world = World::room().at(3, 3); world.facing = Facing::Down; world.npcs = vec![Npc { slot: 4, picture: BALL_SPRITE, x: 3, y: 4, facing: Facing::Up }]; let mut machine = MacroMachine::new(1); let (palette, slot) = pick(&mut world, MacroKind::Talk); machine.start(&palette, slot, &mut world).expect("TALK is bound at the thing ahead"); while machine.step(&mut world).is_some() { world.frame(buttons::NONE); } assert_eq!(machine.take_talked(), None, "nothing is written while the box is open"); // The cartridge takes the joypad: the conversation ended by refusing. world.scripted = true; machine.observe_frame(&mut world); world.scripted = false; machine.observe_frame(&mut world); assert_eq!(machine.take_talked(), None, "a push-back is not a conversation had"); // So it is still something to press A at, and -- once the fly is not standing in front of it, // which is the state `TALK` owns (row 4) -- still something to walk to. assert!(on_the_pad(&mut world, MacroKind::Talk)); world.player = Tile::new(3, 1); assert!( !untalked_objects(&mut world).is_empty(), "the thing the conversation refused is still on offer" ); } #[test] fn a_conversation_that_walks_the_fly_off_its_tile_is_not_talked_to() { // The same fact without the flag: a script can finish its push-back on the frame the box // closes, so where the fly is standing is the other half of the question. let mut world = World::room().at(3, 3); world.facing = Facing::Down; world.npcs = vec![Npc { slot: 4, picture: 1, x: 3, y: 4, facing: Facing::Up }]; let mut machine = MacroMachine::new(1); let (palette, slot) = pick(&mut world, MacroKind::Talk); machine.start(&palette, slot, &mut world).expect("TALK is bound at a person"); while machine.step(&mut world).is_some() { world.frame(buttons::NONE); } world.player = Tile::new(3, 2); machine.observe_frame(&mut world); assert_eq!(machine.take_talked(), None, "the fly is not where it pressed from"); } #[test] fn a_fly_that_declines_an_offer_has_not_talked_to_anything() { // The catching tutorial's own shape: a yes/no box, and `NO` is a real answer the pad has to be // able to give. What it must not do is retire the thing that asked -- the offer stands. // // Since row 56 the box has to be a **readable prompt** for that to be the reading: a `NO` on a // plain text box declines nothing, and the test below is the other half. let mut world = World::room().at(3, 3); world.facing = Facing::Down; world.npcs = vec![Npc { slot: 4, picture: 1, x: 3, y: 4, facing: Facing::Up }]; let mut machine = MacroMachine::new(1); let (palette, slot) = pick(&mut world, MacroKind::Talk); machine.start(&palette, slot, &mut world).expect("TALK is bound at a person"); while machine.step(&mut world).is_some() { world.frame(buttons::NONE); } // The box is open and it is the choice, so the scene is a dialog and the pad is its answers. world.scene = Scene::Dialog; world.prompt = true; let (dialog, no) = pick(&mut world, MacroKind::No); assert_eq!(names(&dialog), ["YES", "NO"], "a readable choice deals its own answers (12.12)"); machine.start(&dialog, no, &mut world).expect("NO is bound in a dialog"); while machine.step(&mut world).is_some() { world.frame(buttons::NONE); } world.scene = Scene::Overworld; machine.observe_frame(&mut world); assert_eq!(machine.take_talked(), None, "a declined offer is not a conversation had"); assert!(on_the_pad(&mut world, MacroKind::Talk), "the offer stands"); } #[test] fn a_no_pressed_inside_a_conversation_is_not_a_declined_offer() { // Row 56, the Pewter Gym guide. His conversation is fifty-two boxes long and `NO`'s B advances // a plain one exactly as `NEXT`'s A does -- it declines nothing. Clearing the pending `TALK` on // it meant the talked ledger never learned the conversation had happened, so `TALK` was on the // overworld pad every hold, and an A press at him reopened the whole ring: thirty brain // minutes of scene `dialog` with no walk macro dealt at all. // // Which of the two a `NO` was is decided where it can be seen: by whether the box closes on it. let mut world = World::room().at(3, 3); world.facing = Facing::Down; world.npcs = vec![Npc { slot: 4, picture: 1, x: 3, y: 4, facing: Facing::Up }]; let mut machine = MacroMachine::new(1); let (palette, slot) = pick(&mut world, MacroKind::Talk); machine.start(&palette, slot, &mut world).expect("TALK is bound at a person"); while machine.step(&mut world).is_some() { world.frame(buttons::NONE); } // Deep inside the conversation: a plain text box, not a choice. world.scene = Scene::Dialog; world.prompt = false; let (dialog, no) = pick(&mut world, MacroKind::No); assert!(names(&dialog).contains(&"NEXT"), "a plain box deals all three: {:?}", names(&dialog)); machine.start(&dialog, no, &mut world).expect("NO is bound in a dialog"); while machine.step(&mut world).is_some() { world.frame(buttons::NONE); } // The box is still open, so nothing is decided yet -- the conversation is still running. machine.observe_frame(&mut world); assert_eq!(machine.take_talked(), None, "the box is still open"); // And when the text is gone the conversation counts, which is what shuts the ring's door. world.scene = Scene::Overworld; machine.observe_frame(&mut world); assert_eq!( machine.take_talked(), Some((world.map, TalkTarget::Sprite(4))), "a conversation walked through to its end is a conversation had" ); world.talked.insert(TalkTarget::Sprite(4)); assert!( !on_the_pad(&mut world, MacroKind::Talk), "`TALK` is the ring's door and this run has been through it" ); } #[test] fn a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to() { // Row 28's other half. Every macro that walked into the gate ended `Done` -- a scene change, // which is "the world moved on under it" and writes nothing -- so `GO OBJECTIVE` and // `GO ROUTE` aimed at the same closed road once per hold for eight hours. A scene change that // is the cartridge *moving the fly* is the target's own fact. let mut world = World::room().at(3, 3); world.map = 0x00; world.connections = Connections { north: true, south: false, east: false, west: false }; // The gate: the scene changes to a dialog four frames in, with the game driving the player. world.switch = Some((4, Scene::Dialog)); world.scripted_at = Some(4); let north = TargetKey::Exit(ExitId::Edge(Edge::North)); assert!(on_the_pad(&mut world, MacroKind::GoRoute)); 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" ); assert!(!on_the_pad(&mut world, MacroKind::GoRoute), "so the button leaves the pad"); // And an ordinary scene change -- a wild battle starting mid-walk -- still records nothing. let mut wild = World::room().at(3, 3); wild.map = 0x00; wild.connections = Connections { north: true, south: false, east: false, west: false }; wild.switch = Some((4, Scene::Battle { own_turn: true, forced_switch: false })); assert_eq!(run(&mut wild, MacroKind::GoRoute), Ok(MacroAbort::Done)); assert!( !wild.targets.blocked(wild.map, north), "a battle is the world moving on, not the road refusing" ); } // --------------------------------------------------------------------------------------------- // Section 12.5: the objective names a target, not just a map // --------------------------------------------------------------------------------------------- #[test] fn an_objective_that_names_a_person_is_walked_to_and_faced() { // Row 29 (`infra/docs/macros-traps.md`). A rung earned by a conversation is not a map: with the // place only naming Oak's lab, `GO OBJECTIVE` arrived on the lab's own doormat and called that // arriving, and `GO OUT` walked straight back out -- live, every three brain seconds, for most // of a morning. The catalog names the person now, and the arrival is `GO NPC`'s: beside it, // facing it, so `TALK` is what the next hold is offered. let mut world = World::room().at(1, 1); world.map = 0x00; world.npcs = vec![Npc { slot: 3, picture: 1, x: 5, y: 5, facing: Facing::Down }]; world.objective = Some(Objective { map: world.map, tile: None, warp: None, edge: None, target: Some(PlaceKind::Person), }); assert!(on_the_pad(&mut world, MacroKind::GoObjective)); assert_eq!(run(&mut world, MacroKind::GoObjective), Ok(MacroAbort::Done)); assert_eq!(world.player.distance(Tile::new(5, 5)), 1, "beside him: {:?}", world.player); let ahead = world.player.step(world.facing); assert_eq!(ahead, Some(Tile::new(5, 5)), "and facing him: {:?} {:?}", world.player, world.facing); // Which is exactly `TALK`'s own precondition, so the press is what the pad now offers -- and // `GO OBJECTIVE` has nothing left to walk to. assert!(on_the_pad(&mut world, MacroKind::Talk)); assert!(!on_the_pad(&mut world, MacroKind::GoObjective), "the walk is done; the press is not"); } #[test] fn the_ways_out_leave_the_pad_while_the_objectives_own_thing_is_here() { // The second half of row 29's fix, and the reason the first rollback stalled: suppressing the // way out is only safe when something else leads to the thing. Knowledge inside the macro -- // a way out is not a candidate while the ladder's target is standing in the room. let mut world = World::ground_floor().at(3, 3); world.npcs = vec![Npc { slot: 3, picture: 1, x: 5, y: 2, facing: Facing::Down }]; world.objective = Some(Objective { map: world.map, tile: None, warp: None, edge: None, target: Some(PlaceKind::Person), }); assert!(!on_the_pad(&mut world, MacroKind::GoOut), "the room the rung is in is not left"); assert!(!on_the_pad(&mut world, MacroKind::GoWarp)); assert!(on_the_pad(&mut world, MacroKind::GoObjective), "and something leads to him"); // The conversation had: the thing is off the objective's list, so the ways out come back. world.talked.insert(TalkTarget::Sprite(3)); assert!(on_the_pad(&mut world, MacroKind::GoOut), "with nothing left here, the door is back"); // And so does an unreachable one, through the blocked ledger -- which is what keeps the room // from becoming a trap when the target is walled off. let mut walled = World::ground_floor().at(3, 3); walled.npcs = vec![Npc { slot: 3, picture: 1, x: 5, y: 2, facing: Facing::Down }]; walled.objective = Some(Objective { map: walled.map, tile: None, warp: None, edge: None, target: Some(PlaceKind::Person), }); assert!(!on_the_pad(&mut walled, MacroKind::GoOut)); walled.targets.record_blocked(walled.map, TargetKey::Thing(TalkTarget::Sprite(3))); assert!(on_the_pad(&mut walled, MacroKind::GoOut), "excluded, so the way out is a candidate"); // A place that is only a map keeps today's behaviour exactly. let mut plain = World::ground_floor().at(3, 3); plain.npcs = vec![Npc { slot: 3, picture: 1, x: 5, y: 2, facing: Facing::Down }]; plain.objective = Some(Objective { map: plain.map, tile: None, warp: None, edge: None, target: None }); assert!(on_the_pad(&mut plain, MacroKind::GoOut)); } // --------------------------------------------------------------------------------------------- // Section 12.6: the battle's own sub-states // --------------------------------------------------------------------------------------------- #[test] fn each_battle_menu_deals_its_own_pad() { // Live 2026-09-17, an hour and forty-one minutes in Viridian Forest: the move list was open // and the pad was one `NEXT`, because `own_turn` was the top-level menu alone and the // between-turns pad is an A press on whatever the cursor is sitting on. The cursor sat on // TACKLE, TACKLE had 0 of 40 PP, the game said so, the box closed, the list came back. // // A list that is accepting input is a choice the game is waiting for, and there are exactly two // answers to a list: choose from it, or back out of it. `NEXT` is on neither. let mut main = World::battle(); let pad = names(&plan::plan_for(main.scene(), &mut main)); assert!(pad.contains(&"MOVE 1"), "{pad:?}"); assert!(pad.contains(&"SWITCH"), "{pad:?}"); // Section 12.10: the top-level menu is a list accepting input too, so `NEXT` is off it as // well. Its backstop is `MOVE 1`, which is bound here whatever the battler reads as, because // FIGHT always opens (12.8) -- and unlike `NEXT` it ends the turn instead of opening the list // that `BACK` closes again. assert!(!pad.contains(&"NEXT"), "never NEXT on a menu accepting input: {pad:?}"); // `ITEM` and `RUN` have their own preconditions -- a potion, and a party with nothing healthy // left -- and this fixture satisfies neither; row 7 covers the turn where all four drop. let mut moves = World::battle(); moves.list = List::Moves(3); moves.cursor = 0; let pad = names(&plan::plan_for(moves.scene(), &mut moves)); assert!(pad.contains(&"MOVE 1"), "{pad:?}"); assert!(pad.contains(&"BACK"), "{pad:?}"); assert!(!pad.contains(&"NEXT"), "never NEXT over an open list: {pad:?}"); assert!(!pad.contains(&"SWITCH"), "the move list is not where a switch is chosen: {pad:?}"); assert!(!pad.contains(&"RUN"), "{pad:?}"); let mut party = World::battle(); party.list = List::BattleParty; party.cursor = 1; let pad = names(&plan::plan_for(party.scene(), &mut party)); assert!(pad.contains(&"SWITCH"), "{pad:?}"); assert!(pad.contains(&"BACK"), "{pad:?}"); assert!(!pad.contains(&"NEXT"), "{pad:?}"); assert!(!pad.contains(&"ATTACK"), "{pad:?}"); // A forced switch is its own scene and its own pad, and it keeps the `NEXT` that stops a // forced switch with nothing to switch to from dealing an empty pad (row 8). let mut forced = World::battle(); forced.scene = Scene::Battle { own_turn: false, forced_switch: true }; forced.battle = Some((BattleKind::Wild, false, true)); forced.list = List::BattleParty; let pad = names(&plan::plan_for(forced.scene(), &mut forced)); assert!(pad.contains(&"SWITCH"), "{pad:?}"); assert!(pad.contains(&"NEXT"), "{pad:?}"); assert!(!pad.contains(&"BACK"), "a forced switch cannot be backed out of: {pad:?}"); // And a battle frame with no menu up is still the between-turns press, which is the 2026-09-16 // deadlock fix and is untouched. let mut between = World::battle(); between.scene = Scene::Battle { own_turn: false, forced_switch: false }; between.battle = Some((BattleKind::Wild, false, false)); between.list = List::None; let pad = names(&plan::plan_for(between.scene(), &mut between)); assert_eq!(pad.iter().filter(|name| **name == "NEXT").count(), 1, "{pad:?}"); assert!(!pad.contains(&"BACK"), "nothing is open to back out of: {pad:?}"); } /// Section 12.9: `BACK` is on a battle's pad only where a list is open. /// /// **Live on rung 9**, 69 hours in Viridian Forest: `BACK` was 135 of 183 macro starts since the /// restart and the event log repeated `RUN blocked, BACK start, BACK done`. Between turns there is /// no list to leave, so the B press changes nothing the `NEXT` beside it does not and the macro /// completes on the tile it started on -- section 12.2's trap, on the pad the fly spends most of a /// wild battle looking at. /// /// The three lists keep it, because backing out of a list is one of exactly two answers to one. #[test] fn back_is_on_a_battle_pad_only_where_a_list_is_open() { let with_back = |world: &mut World| { let pad = names(&plan::plan_for(world.scene(), world)); assert!(pad.contains(&"BACK"), "a list can be left: {pad:?}"); }; let without = |world: &mut World| { let pad = names(&plan::plan_for(world.scene(), world)); assert!(!pad.contains(&"BACK"), "nothing to back out of: {pad:?}"); assert!(plan::plan_for(world.scene(), world).bound() > 0, "and never an empty pad"); }; // The top-level menu: FIGHT, PKMN, ITEM and RUN are the four answers and B is not a fifth. let mut main = World::battle(); without(&mut main); // The three lists. let mut moves = World::battle(); moves.list = List::Moves(3); with_back(&mut moves); let mut party = World::battle(); party.list = List::BattleParty; with_back(&mut party); // The bag, which is the fly's own turn since section 12.10 because its cursor accepts input. let mut bag = World::battle(); bag.list = List::BattleBag; with_back(&mut bag); // Text, an animation, a turn resolving: no list, no `BACK`. let mut between = World::battle(); between.scene = Scene::Battle { own_turn: false, forced_switch: false }; between.battle = Some((BattleKind::Wild, false, false)); between.list = List::None; without(&mut between); // And a forced switch, which could never be backed out of anyway (row 8). let mut forced = World::battle(); forced.scene = Scene::Battle { own_turn: false, forced_switch: true }; forced.battle = Some((BattleKind::Wild, false, true)); forced.list = List::BattleParty; without(&mut forced); } /// Section 12.10: **no battle pad deals a pair of buttons that undo each other.** /// /// Live on rung 9 after v0.4.2, 71 hours in Viridian Forest: `NEXT` 1264 macro starts, `BACK` /// 1241, and the event log alternating `NEXT start/done, BACK start/done` every hold on map 51. /// The pair was split across two sub-states of one turn -- `NEXT` on the top-level menu was an A /// press on FIGHT, which opened the move list, and `BACK` on the move list closed it again -- so /// neither the pad rule of 12.9 nor the "no `BACK` without a list" rule caught it: both buttons /// were legitimate where they stood, and between them they were a 2-cycle that never spent a turn. /// /// The rule that closes it is about the pair rather than about either button: `NEXT` is the A that /// advances **text**, so it belongs only on a frame with no cursor accepting input, and `BACK` is /// the B that leaves a **list**, so it belongs only on a frame that has one. The two conditions are /// exclusive, so no pad can hold both -- and the forced switch, which keeps `NEXT` as row 8's /// backstop, is the one arm with a cursor and no `BACK` at all, because it cannot be cancelled. #[test] fn no_battle_pad_holds_both_next_and_back() { // Every battle sub-state the seam can report, on a turn where every precondition is satisfied // (a potion, a ball, a hurt Pokémon, a bench) and on one where none is. let sub_states = [ (Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleMain), (Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::Moves(3)), (Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleParty), (Scene::Battle { own_turn: true, forced_switch: false }, true, false, List::BattleBag), (Scene::Battle { own_turn: false, forced_switch: false }, false, false, List::None), (Scene::Battle { own_turn: false, forced_switch: true }, false, true, List::BattleParty), ]; for stocked in [false, true] { for (scene, own_turn, forced, list) in sub_states { let mut world = World::battle(); world.scene = scene; world.battle = Some((BattleKind::Wild, own_turn, forced)); world.list = list; if stocked { world.bag = vec![(item::POTION, 1), (item::POKE_BALL, 3)]; world.enemy = Some(EnemyMon { species: 0x99, level: 3, hp: 5, max_hp: 11 }); } let pad = names(&plan::plan_for(world.scene(), &mut world)); let next = pad.contains(&"NEXT"); let back = pad.contains(&"BACK"); assert!( !(next && back), "{list:?} (stocked {stocked}) deals a pair that undoes itself: {pad:?}" ); // And the pair is not the only way to waste a hold: a pad of one button that cannot // end the turn is the shape row 34 had, so every sub-state is checked for having one. assert!( !pad.is_empty(), "{list:?} (stocked {stocked}) deals nothing: {pad:?}" ); // `NEXT` is on a frame with no cursor accepting input, or on the forced switch that // has no other answer (row 8). Nowhere else. if next { assert!( forced || list == List::None, "NEXT on a menu accepting input: {list:?} deals {pad:?}" ); } } } } /// Section 12.10: the bag inside a battle is the fly's turn, and its pad is the bag's answers. /// /// `NEXT` on an open bag is the A press that **uses** whatever the cursor is sitting on, which is /// not one of the answers to a list, and `CONFIRM` beside `BACK` was the same press by another /// name. The two scripts that navigate this list by reading its cursor are `ITEM` and /// `THROW BALL`, and they are what the row deals. #[test] fn the_battle_bag_is_the_flys_turn_and_deals_its_own_two_uses() { let mut world = World::battle(); world.list = List::BattleBag; world.bag = vec![(item::POTION, 2), (item::POKE_BALL, 4)]; world.enemy = Some(EnemyMon { species: 0x99, level: 3, hp: 5, max_hp: 11 }); // `World::battle` is a wild battle with the active Pokémon on 4 of 20, so `ITEM`'s two facts // hold and `THROW BALL`'s four do: a wild battle, a ball, room in a party of three, and an // enemy species the party does not hold. assert_eq!( names(&plan::plan_for(world.scene(), &mut world)), ["BACK", "ITEM", "THROW BALL"] ); // Nothing in the bag: leaving the list is the press, and there is no `NEXT` to use a thing // that is not there. world.bag.clear(); let pad = names(&plan::plan_for(world.scene(), &mut world)); assert_eq!(pad, ["BACK"]); } #[test] fn a_spent_slot_is_off_the_pad_and_a_slot_with_pp_is_on_it_over_an_open_list() { // The live state row 34 came from: slot 0 is TACKLE with 0 of 40 PP and the cursor is on it. // Section 14 makes that a *per slot* question -- `MOVE 1` is gone from the pad and `MOVE 2` // and `MOVE 3` are on it -- and the script goes straight to the slot rather than through // FIGHT. let mut world = World::battle(); world.mons = vec![mon(0, 8, 28, &[(33, 0), (45, 40), (73, 10)])]; world.active = Some(0); world.list = List::Moves(3); world.grid = false; world.cursor = 0; world.cursor_max = 2; assert!(!on_the_pad(&mut world, MacroKind::Move1), "slot one is spent"); assert!(on_the_pad(&mut world, MacroKind::Move2)); assert!(on_the_pad(&mut world, MacroKind::Move3)); assert_eq!(run(&mut world, MacroKind::Move3), Ok(MacroAbort::Done)); assert_eq!(world.cursor, 2, "the cursor moved onto the slot the button names"); assert!(world.pulses.contains(&buttons::A), "and confirmed it: {:?}", world.pulses); } #[test] fn move_one_confirms_anyway_when_every_move_is_out_of_pp() { // Struggle is the cartridge's answer to a Pokémon with no PP anywhere, and the way to it is to // choose a move regardless. Refusing would take every move button off the pad on the one turn // the fly has nothing else to press -- which is the shape the live loop had. let mut world = World::battle(); world.mons = vec![mon(0, 8, 28, &[(33, 0), (45, 0), (73, 0)])]; world.active = Some(0); world.list = List::Moves(3); world.cursor = 1; world.grid = false; world.cursor_max = 2; assert!(on_the_pad(&mut world, MacroKind::Move1), "`MOVE 1` carries Struggle"); assert!(!on_the_pad(&mut world, MacroKind::Move2), "and it alone"); assert_eq!(run(&mut world, MacroKind::Move1), Ok(MacroAbort::Done)); assert_eq!(world.cursor, 1, "the cursor's own slot is the choice"); assert!(world.pulses.contains(&buttons::A)); // And from the menu above, with nothing to attack with, `MOVE 1` presses FIGHT and stops // there: `CheckPlayerHasUsableMoves` answers it without opening a list, so a second cursor // step would press A at text. Row 34 -- this used to refuse, which is what left the turn with // `ITEM` over a bag it could only close. let mut menu = World::battle(); menu.mons = vec![mon(0, 8, 28, &[(33, 0)])]; menu.active = Some(0); menu.list = List::BattleMain; menu.cursor = battle_entry::RUN; assert!(on_the_pad(&mut menu, MacroKind::Move1)); assert_eq!(run(&mut menu, MacroKind::Move1), Ok(MacroAbort::Done)); assert_eq!(menu.cursor, battle_entry::FIGHT, "the cursor moved onto FIGHT"); assert!(menu.pulses.contains(&buttons::A), "and confirmed it: {:?}", menu.pulses); } #[test] fn a_turn_with_nothing_to_attack_switch_or_flee_with_still_has_a_button() { // The state measured in Viridian Forest at 99.1 brain minutes (`infra/docs/macros-traps.md` // row 34): a wild battle, a party of one, TACKLE / GROWL / LEECH SEED all at 0 PP, a potion in // the bag. Before this branch the fly's own turn dealt `ITEM` and `NEXT` and nothing that // could end the turn; `NEXT` was an A press on whatever the cursor held, which opened the // party list, whose only bound button with one Pokémon is `BACK`. let mut world = World::battle(); world.mons = vec![mon(0, 11, 34, &[(33, 0), (45, 0), (73, 0)])]; world.active = Some(0); world.bag = vec![(item::POTION, 1)]; world.list = List::BattleMain; assert!(!move_slot_bound(&mut world, MacroKind::Move2), "nothing but slot one is offered"); assert!(healthiest_other(&mut world).is_none(), "a party of one has no bench"); // Every battle sub-state of that same state. The two lists that *are* a choice deal a button // that ends the turn; the party list, whose one entry is the Pokémon already out, has nothing // to choose and backing out is the only press -- which now returns to a menu with `MOVE 1` on // it, where before this branch it returned to `ITEM` and `NEXT`. for list in [List::BattleMain, List::Moves(3)] { world.list = list; let pad = pad_of(&mut world); assert!(pad.contains(&"MOVE 1"), "{list:?} deals {pad:?}"); assert!(pad.len() > 1, "{list:?} deals one button: {pad:?}"); assert_ne!(pad, vec!["BACK"], "no battle pad is BACK alone: {list:?}"); } world.list = List::BattleParty; assert_eq!( pad_of(&mut world), vec!["BACK"], "the party list with one Pokémon: nothing to switch to, so backing out is the press" ); // And what it backs out to is a turn the fly can end. world.list = List::BattleMain; assert!(pad_of(&mut world).contains(&"MOVE 1")); } /// The bound buttons of the macros-mode pad for the scene the world is in, unbound slots dropped. fn pad_of(world: &mut World) -> Vec<&'static str> { let scene = world.scene(); names(&plan::plan_for(scene, world)).into_iter().filter(|name| *name != "-").collect() } // --------------------------------------------------------------------------------------------- // Section 13: shops and Pokémon Centers // --------------------------------------------------------------------------------------------- /// A town map with one door into the mart and one into the centre, so an errand has something to /// aim at from outside. /// /// Viridian City's own id, because that is the area [`geography`](super::geography)'s table has /// rows for; the two warps are the only thing this fixture needs to be right about. fn viridian() -> World { let mut world = World::room(); world.map = maps::VIRIDIAN_CITY; world.size = MapSize { width: 8, height: 8 }; world.player = Tile::new(3, 6); world.warps = vec![ Warp { x: 1, y: 1, destination_warp: 0, destination_map: maps::VIRIDIAN_MART }, Warp { x: 6, y: 1, destination_warp: 0, destination_map: maps::VIRIDIAN_POKECENTER }, ]; world.money = 3_000; world } #[test] fn the_errand_is_outstanding_once_per_area_per_run() { let mut world = viridian(); assert_eq!(errand(&mut world, Amenity::Mart), Some(maps::VIRIDIAN_MART)); assert_eq!(errand(&mut world, Amenity::Center), Some(maps::VIRIDIAN_POKECENTER)); // Paid: the ledger entry takes it off, and it never comes back this run. world.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY)); assert_eq!(errand(&mut world, Amenity::Mart), None); assert_eq!(errand(&mut world, Amenity::Center), Some(maps::VIRIDIAN_POKECENTER)); // The ledger is per area, so the same kind in another town is a different errand -- and an // area the table has no row for has none at all. world.map = maps::PEWTER_CITY; assert_eq!(errand(&mut world, Amenity::Mart), Some(maps::PEWTER_MART)); world.map = maps::PALLET_TOWN; assert_eq!(errand(&mut world, Amenity::Mart), None, "Pallet Town has neither"); world.map = maps::ROUTE_1; assert_eq!(errand(&mut world, Amenity::Center), None, "a route has neither"); } #[test] fn the_mart_errand_is_skipped_when_the_money_is_under_the_cheapest_purchase() { let mut world = viridian(); world.money = CHEAPEST_PURCHASE; assert_eq!(errand(&mut world, Amenity::Mart), Some(maps::VIRIDIAN_MART)); world.money = CHEAPEST_PURCHASE - 1; assert_eq!(errand(&mut world, Amenity::Mart), None, "nothing it could buy when it got there"); // The centre costs nothing, so it is never skipped for money. assert_eq!(errand(&mut world, Amenity::Center), Some(maps::VIRIDIAN_POKECENTER)); } #[test] fn the_errand_comes_ahead_of_the_rungs_place_and_the_centre_first_when_the_party_is_hurt() { let mut world = viridian(); // The ladder is pointing two maps north; the errand is in this town. world.objective = Some(Objective { map: maps::VIRIDIAN_FOREST, tile: None, warp: None, edge: None, target: None, }); // Healthy party: the mart first, so `GO OBJECTIVE` aims at the mart's door (warp 0 at (1, 1)). assert!(!party_needs_rest(&mut world)); let goals = objective_goals(&mut world); assert_eq!( goals.iter().map(|aim| aim.tile).collect::>(), vec![Tile::new(1, 1)], "the mart's door, not the way north" ); // Hurt party: the centre first, because a hurt party is what ends runs. world.mons[0].hp = 9; assert!(party_needs_rest(&mut world)); assert_eq!( objective_goals(&mut world).iter().map(|aim| aim.tile).collect::>(), vec![Tile::new(6, 1)] ); // Both errands paid: the rung's place is what is left, and with no route the graph knows from // an eight-by-eight fake town the two doors are what the fall-through offers -- the point being // that the *errand* is no longer what is aimed at. world.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY)); world.areas.insert((Amenity::Center, maps::VIRIDIAN_CITY)); assert_eq!(errand(&mut world, Amenity::Mart), None); assert_eq!(errand(&mut world, Amenity::Center), None); let goals = objective_goals(&mut world); assert!( !goals.iter().any(|aim| aim.tile == Tile::new(1, 1) && goals.len() == 1), "the mart's door is no longer the single answer" ); } #[test] fn go_shop_and_go_heal_are_on_the_pad_while_their_errand_stands() { let mut world = viridian(); assert!(on_the_pad(&mut world, MacroKind::GoShop)); assert!(on_the_pad(&mut world, MacroKind::GoHeal)); // Paid, and the button leaves the pad -- which is section 12's "a precondition failure means // the button is not on the pad", and what makes the errand impossible to loop on. world.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY)); world.areas.insert((Amenity::Center, maps::VIRIDIAN_CITY)); assert!(!on_the_pad(&mut world, MacroKind::GoShop)); assert!(!on_the_pad(&mut world, MacroKind::GoHeal)); // And in Pallet Town, which has neither, they were never on it. let mut pallet = World::room(); pallet.map = maps::PALLET_TOWN; pallet.money = 3_000; assert!(!on_the_pad(&mut pallet, MacroKind::GoShop)); assert!(!on_the_pad(&mut pallet, MacroKind::GoHeal)); } #[test] fn an_edge_the_table_cannot_name_stops_being_somewhere_new_once_it_is_stood_on() { // The rung-11 reading of row 54 (`infra/docs/macros-traps.md`). The cartridge reports Route // 3's connections as north and west; `geography`'s row carries west and east, so the north // edge's destination is unnameable -- and an unnameable destination counted as *unvisited*, // which made those tiles first-tier for `GO ROUTE` on every hold for ever, with // `GO OBJECTIVE` off the pad beside them because nothing on this map leads to the objective. let mut world = World::room(); world.map = maps::ROUTE_3; world.size = MapSize { width: 8, height: 8 }; world.player = Tile::new(4, 4); world.connections = Connections { north: true, south: false, east: false, west: true }; // West is Pewter City, which the table does name and the run has stood on. world.seen_maps.insert(maps::PEWTER_CITY); let north: Vec = ways(&mut world, Way::Route).iter().map(|exit| exit.id).collect(); assert!( north.iter().all(|id| *id == ExitId::Edge(Edge::North)), "the unnameable north edge is the only fresh way out: {north:?}" ); // Stood on, and it is no longer somewhere new -- so the walk falls to the tier that leads // toward the objective instead of aiming at the same edge once per hold for ever. world.visited.insert(ExitId::Edge(Edge::North)); let left: Vec = ways(&mut world, Way::Route).iter().map(|exit| exit.id).collect(); assert!( !left.contains(&ExitId::Edge(Edge::North)), "an edge nothing can name, already crossed, is not first-tier: {left:?}" ); } #[test] fn an_errand_does_not_settle_on_the_doormat_it_is_standing_on() { // Row 54 of `infra/docs/macros-traps.md`, and section 12.2's rule: "a macro that completes // without moving because its precondition is already satisfied where the fly stands is a // trap". An errand's aim at a door carries no press -- the warp fires when it is stepped on -- // so an aim on the tile the fly is already on settles for `SETTLE_FRAMES` and reports `done` // with the world exactly as it was. A completed errand walk writes the reached ledger, which // `goals_toward` does not filter, so the same button was dealt on the next hold and the same // nothing happened again: `GO HEAL` 204 starts at a mean net of 0.0 tiles and a mean reach of // 0.0, in a cycle with `GO ROUTE` and `GO FRONTIER` over five tiles. let mut world = viridian(); world.player = Tile::new(6, 1); assert!( amenity_goals(&mut world, Amenity::Center).is_empty(), "the centre's own doormat is not somewhere to walk to" ); assert!(!on_the_pad(&mut world, MacroKind::GoHeal), "so the button is not on the pad"); // The other errand is a tile away and untouched: this excludes one aim, not the walk. assert_eq!( amenity_goals(&mut world, Amenity::Mart).iter().map(|aim| aim.tile).collect::>(), vec![Tile::new(1, 1)] ); assert!(on_the_pad(&mut world, MacroKind::GoShop)); // And one tile off the doormat the centre is a walk again. world.player = Tile::new(6, 2); assert_eq!( amenity_goals(&mut world, Amenity::Center).iter().map(|aim| aim.tile).collect::>(), vec![Tile::new(6, 1)] ); assert!(on_the_pad(&mut world, MacroKind::GoHeal)); } #[test] fn an_errand_is_paid_by_a_building_this_run_has_already_been_inside() { // The errand ledger is session state and the adapter's map ledger is not, so a restored run // re-armed every errand in the town and walked back to a counter it had already used // (`docs/design/macros.md` section 13's own residual). Asking both is asking "has this run // been in there" twice, and either answer pays the errand. let mut world = viridian(); assert_eq!(errand(&mut world, Amenity::Center), Some(maps::VIRIDIAN_POKECENTER)); world.seen_maps.insert(maps::VIRIDIAN_POKECENTER); assert_eq!(errand(&mut world, Amenity::Center), None, "already been inside it"); assert!(!on_the_pad(&mut world, MacroKind::GoHeal)); // The mart is a different building and a different errand. assert_eq!(errand(&mut world, Amenity::Mart), Some(maps::VIRIDIAN_MART)); assert!(on_the_pad(&mut world, MacroKind::GoShop)); } #[test] fn go_shop_walks_to_the_marts_door_and_then_to_the_counter() { // Outside: the goal is the door, and it is the warp's own tile. let mut world = viridian(); let goals = amenity_goals(&mut world, Amenity::Mart); assert_eq!(goals.iter().map(|aim| aim.tile).collect::>(), vec![Tile::new(1, 1)]); assert_eq!(run(&mut world, MacroKind::GoShop).unwrap(), MacroAbort::Done); assert_eq!(world.player, Tile::new(1, 1), "standing on the mart's doormat"); // Inside, the errand is already paid -- the driver writes the ledger on arrival -- and the // button's remaining job is the counter. The clerk's four sides are all walls, so the only // goal is the tile *over* the counter. let mut mart = World::mart(); mart.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY)); let goals = amenity_goals(&mut mart, Amenity::Mart); // The reach over the counter is what puts a standable tile in the list at all: every tile // beside the clerk is a wall, so without it there is nothing to walk to. assert!( goals.contains(&super::palette::Aim { tile: Tile::new(2, 5), press: None, face: Some(Facing::Left), key: goals[0].key, }), "over the counter at (1, 5), facing the clerk at (0, 5): {goals:?}" ); // The other standable aims are the two tiles *past* the counter above and below the clerk, // which the map-id reach also offers; the route search is what picks among them, and in this // fake it picks the one it can reach first. assert!( goals .iter() .filter(|aim| mart.walkable(aim.tile.x, aim.tile.y) == Walkable::Yes) .all(|aim| aim.tile.distance(Tile::new(0, 5)) == 2), "every standable aim is two tiles from the clerk, over something: {goals:?}" ); assert!(on_the_pad(&mut mart, MacroKind::GoShop), "still on the pad, aimed at the counter"); assert_eq!(run(&mut mart, MacroKind::GoShop).unwrap(), MacroAbort::Done); assert_eq!( mart.player.distance(Tile::new(0, 5)), 2, "two tiles from the clerk, over something: {:?}", mart.player ); } #[test] fn a_counter_is_the_only_way_to_reach_a_clerk_or_a_nurse() { // Without the counter rule the four tiles around the clerk are the whole candidate list, and // every one of them is a wall: the walk finds no route and `GO SHOP` refuses once per hold for // ever. This is that fact, stated from both sides. // The four tiles *beside* a clerk behind a desk: not one of them is standable, which is the // whole reason the reach exists. let mut mart = World::mart(); let clerk = Tile::new(0, 5); for facing in FACINGS { let Some(beside) = clerk.step(facing) else { continue }; assert_ne!( mart.walkable(beside.x, beside.y), Walkable::Yes, "{beside:?} is beside the clerk and standable" ); } let mut center = World::center(); let goals = heal_goals(&mut center); assert!( goals.contains(&super::palette::Aim { tile: Tile::new(3, 3), press: None, face: Some(Facing::Up), key: goals[0].key, }), "over the counter at (3, 2), facing the nurse at (3, 1): {goals:?}" ); // And the nurse's four sides, likewise. let nurse = Tile::new(3, 1); for facing in FACINGS { let Some(beside) = nurse.step(facing) else { continue }; assert_ne!( center.walkable(beside.x, beside.y), Walkable::Yes, "{beside:?} is beside the nurse and standable" ); } // The reach is taken from the *map id* as well as from the tile, because the tile read is // unreliable on a map smaller than the screen (`palette::facing_target`). So clearing the // fake's counter tiles does **not** take it away inside a centre, and that is the point: the // map id is a byte the adapter already has and it does not move. center.counters.clear(); assert!( heal_goals(&mut center).iter().any(|aim| aim.tile == Tile::new(3, 3)), "the reach survives a tile read that has gone wrong: {:?}", heal_goals(&mut center) ); } #[test] fn heal_is_on_the_centres_pad_only_while_the_party_needs_it() { let mut center = World::center(); // A full, healthy party: nothing to rest, so the button is not there. assert!(party_rested(&mut center)); 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)); assert!(on_the_pad(&mut center, MacroKind::Heal)); // Statused at full HP counts too, which is what "not at full HP *or* has a status" means. center.mons[0].hp = 20; center.mons[0].status = Status::Poison; assert!(party_needs_rest(&mut center)); assert!(precondition(MacroKind::Heal, &mut center)); // And nowhere but a centre, whatever the party looks like. let mut mart = World::mart(); mart.mons[0].hp = 1; assert!(!precondition(MacroKind::Heal, &mut mart)); assert!(!on_the_pad(&mut mart, MacroKind::Heal)); } #[test] fn heal_walks_to_the_counter_talks_answers_yes_and_waits_for_the_party_to_read_full() { let mut center = World::center(); center.mons[0].hp = 3; center.mons[0].status = Status::Poison; // The nurse's box opens once the macro has walked to the counter and pressed A, so the scene // changes *under* the macro -- which every other macro treats as the end of it. `HEAL` // declares that it spans the two classes, because the conversation is the macro (section 13). // The walk is three tiles at sixteen frames each plus the arrival press, so 200 is after it. center.switch = Some((200, Scene::Dialog)); // And the cartridge heals the party while the box is open, which is what the wait reads. center.heal_at = Some(240); assert_eq!(run(&mut center, MacroKind::Heal).unwrap(), MacroAbort::Done); assert_eq!(center.player, Tile::new(3, 3), "at the counter"); assert_eq!(center.facing, Facing::Up, "facing the nurse"); assert!(party_rested(&mut center), "the party reads back full and healthy"); assert!( center.pulses.iter().filter(|mask| **mask == buttons::A).count() >= 2, "the talk and the YES at least: {:?}", center.pulses ); // And the precondition is gone, so the button leaves the pad rather than being pressed again. center.scene = Scene::Overworld; assert!(!precondition(MacroKind::Heal, &mut center)); } #[test] fn a_purchase_navigates_by_the_items_place_in_the_stock_list() { // Viridian's counter in menu order: the Antidote is index 1, so that is the cursor the script // aims at -- read from the stock list, never counted in presses. let mut world = World::room(); world.scene = Scene::Shop; world.list = List::Shop(ShopScreen::Buying); world.cursor_max = 3; world.stock = vec![item::POKE_BALL, item::ANTIDOTE, 15, 12]; world.money = 3_000; assert_eq!(run(&mut world, MacroKind::BuyAntidote).unwrap(), MacroAbort::Done); assert_eq!(world.cursor, 1, "the Antidote's own index"); assert_eq!(world.pulses.last(), Some(&buttons::A), "the price confirmation"); // An item the counter does not stock has no index, so the script refuses before anything is // pressed: `BUY REPEL` in Viridian. let mut world = World::room(); world.scene = Scene::Shop; world.list = List::Shop(ShopScreen::Buying); world.stock = vec![item::POKE_BALL, item::ANTIDOTE]; world.money = 3_000; let scene = world.scene(); let palette = Palette::for_scene(scene, &mut world); assert!( palette.slots.iter().flatten().all(|spec| spec.kind != MacroKind::BuyRepel), "not on the pad at a counter that does not stock it" ); assert!(world.pulses.is_empty()); } #[test] fn every_macro_type_has_a_population_a_tag_and_a_gloss() { // The three ends of section 11's rule, over the twenty-seven types section 13 leaves. assert_eq!(MacroKind::ALL.len(), 31); assert_eq!(SLOTS, MacroKind::ALL.len(), "a slot per type"); assert_eq!(MacroKind::BY_CHANNEL.len(), MacroKind::ALL.len()); let mut channels: Vec<&str> = MacroKind::ALL.iter().map(|kind| kind.channel()).collect(); let mut tags: Vec<&str> = MacroKind::ALL.iter().map(|kind| kind.channel_tag()).collect(); channels.sort_unstable(); tags.sort_unstable(); let unique = |mut list: Vec<&str>| { let before = list.len(); list.dedup(); list.len() == before }; assert!(unique(channels), "a channel means one action"); assert!(unique(tags), "a tag names one cell"); for kind in MacroKind::ALL { assert!(MacroKind::BY_CHANNEL.contains(&kind), "{}", kind.name()); assert!(kind.name().len() <= 14, "{}", kind.name()); assert!(kind.channel_tag().chars().count() <= 8, "{}", kind.channel_tag()); assert_eq!( MacroKind::BY_CHANNEL[usize::from(kind.slot())], kind, "{} is not at its own slot", kind.name() ); assert!(kind.gloss().split_whitespace().count() <= 3, "{}", kind.name()); } } /// Every scene and sub-state deals at least one button (section 13.1's audit, as an assertion). /// /// The sweep that catches an empty pad, which is the one state the doctrine cannot recover from on /// its own: nothing presses for the fly, so a scene with no buttons waits for ever. /// /// **What the overworld's half rests on since section 12.11** is the way out and no longer the /// unconditional `MENU`: every map in the game has one -- an interior's front door or its /// staircase, an outdoor map's connection -- and `ways`' last resort offers it regardless of the /// ledgers when the map holds nothing else worth walking to. So the fixtures below have their /// doors, where the old ones did not need them, and the map with *no way out at all* is the named /// residual at the end of this test rather than a case the rule covers. #[test] fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() { let worst = |world: &mut World| { let scene = world.scene(); assert!( plan::plan_for(scene, world).bound() > 0, "{} deals nothing", scene.label() ); }; // The overworld, in all three of its rows, on a map with nothing on it but its own door. let mut bare = World::ground_floor(); bare.seen_maps.insert(0x00); bare.seen_maps.insert(0x26); bare.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect(); assert!(super::palette::stranded(&mut bare), "nothing on this floor to walk to"); worst(&mut bare); let mut outdoors = viridian(); outdoors.warps.clear(); outdoors.connections = Connections { north: true, south: false, east: false, west: false }; outdoors.seen_maps.insert(maps::ROUTE_2); outdoors.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY)); outdoors.areas.insert((Amenity::Center, maps::VIRIDIAN_CITY)); outdoors.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect(); worst(&mut outdoors); let mut center = World::center(); center.npcs.clear(); worst(&mut center); // Every other scene, with every precondition false: no stock, no money, no party. for scene in [ Scene::Dialog, Scene::Unknown, Scene::Menu, Scene::Shop, Scene::Pc, Scene::Battle { own_turn: false, forced_switch: false }, Scene::Battle { own_turn: true, forced_switch: true }, ] { let mut world = World::room(); world.scene = scene; // See the sweep in `no_playable_scene_deals_an_empty_pad`: `Unknown` with nothing drawn // on it is the overworld being driven by the cartridge, and its pad is empty by design. world.box_open = scene == Scene::Unknown; world.mons.clear(); worst(&mut world); } // The fly's own turn in a battle it cannot attack, switch, item or flee in. let mut cornered = World::battle(); cornered.mons = vec![mon(0, 20, 20, &[])]; cornered.battle = Some((BattleKind::Trainer, true, false)); worst(&mut cornered); // And every battle sub-state of that same cornered turn, which since section 12.10 includes // the bag: an empty bag deals `BACK` and nothing else, which is row 34a's answer -- there is // nothing to choose, so leaving the list is the press, and what it leaves to is a menu with // `MOVE 1` on it. for list in [List::BattleMain, List::Moves(1), List::BattleParty, List::BattleBag] { cornered.list = list; worst(&mut cornered); } // And the one overworld that still deals nothing, said out loud rather than papered over: a // map with **no way out at all**, every tile stood on and nothing on it. No map in Red is // that -- an interior has its front door or its staircase and an outdoor map has its // connections -- so this is a shape the cartridge does not hold, and `game.padEmptyMs` is what // would report it if one ever did (section 13.1). let mut sealed = World::room(); sealed.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect(); assert!(path::exits(&mut sealed).is_empty(), "the fixture really has no way out"); assert_eq!(plan::plan_for(Scene::Overworld, &mut sealed).bound(), 0); } /// Section 13.1's pad-empty rule: an outdoor map with every ledger against it still offers a walk. /// /// The operator, on stream 2026-09-17: "sometimes the macro buttons disappear and everything just hangs /// there." The state below is the one that produces it — every route visited, nothing untalked, /// the near ground covered, no objective — and before this rule the pad was `MENU` and nothing /// else, which is a pad that cannot move the fly one tile. #[test] fn a_map_with_every_ledger_against_it_still_offers_a_way_out() { let mut world = viridian(); world.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY)); world.areas.insert((Amenity::Center, maps::VIRIDIAN_CITY)); world.warps.clear(); world.connections = Connections { north: true, south: false, east: false, west: false }; // The map on the other side has been stood on, so tier 1 is empty; there is no objective, so // tier 2 is empty. world.seen_maps.insert(maps::ROUTE_2); // And every tile of it has been stood on, so the frontier is empty in both of its readings. world.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect(); assert!(super::palette::stranded(&mut world), "nothing else on this map to walk to"); assert!( !ways(&mut world, Way::Route).is_empty(), "the visited connection comes back as the last resort" ); assert!(on_the_pad(&mut world, MacroKind::GoRoute)); // And it is *only* a last resort: give the map one unstood tile and the visited connection // goes away again, because row 2's loop -- in and out of the same door, once per hold -- is // what an unconditional tier 3 buys. world.stood.remove(&Tile::new(0, 0)); assert!(!super::palette::stranded(&mut world)); assert!(ways(&mut world, Way::Route).is_empty()); assert!(on_the_pad(&mut world, MacroKind::GoFrontier)); } /// The exit *toward the objective* is the one the last resort prefers, when the graph knows one. #[test] fn the_last_resort_prefers_the_exit_toward_the_objective() { let mut world = viridian(); world.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY)); world.areas.insert((Amenity::Center, maps::VIRIDIAN_CITY)); world.warps.clear(); world.connections = Connections { north: true, south: true, east: false, west: false }; world.seen_maps.insert(maps::ROUTE_1); world.seen_maps.insert(maps::ROUTE_2); world.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect(); world.objective = Some(Objective { map: maps::PEWTER_CITY, tile: None, warp: None, edge: None, target: None, }); // `GO OBJECTIVE` is on the pad here -- its own goals ignore the visited ledger -- so the map is // not stranded at all, which is the point: the last resort is for a map with *nothing*. assert!(!super::palette::stranded(&mut world)); // Exclude the north edge and the objective has nowhere to aim either. world.targets.record_blocked(world.map, TargetKey::Exit(ExitId::Edge(Edge::North))); world.targets.record_blocked(world.map, TargetKey::Exit(ExitId::Edge(Edge::South))); assert!(super::palette::stranded(&mut world)); // Pewter is north of Viridian by way of Route 2, so the north edge is what comes back -- and // it comes back *although the blocked ledger is resting it*, because a resting target is // still the only place to go. let last = ways(&mut world, Way::Route); assert!( last.iter().all(|exit| exit.id == ExitId::Edge(Edge::North)), "toward Pewter and not away from it: {last:?}" ); } /// Section 14's `THROW BALL` (the operator: "throw pokeball should be a macro"). /// /// The precondition is three facts and no judgement: a wild battle, a ball in the bag, and room in /// the party. When to throw is the fly's -- there is no catch-rate and no enemy-HP knowledge here, /// and there is none anywhere in this crate. #[test] fn throw_ball_needs_a_wild_battle_a_ball_and_room_in_the_party() { let mut world = World::battle(); world.bag = vec![(item::POKE_BALL, 3)]; assert_eq!(throw_slot(&mut world), Some(0)); assert!(on_the_pad(&mut world, MacroKind::ThrowBall)); // Any ball kind, at its own bag index. world.bag = vec![(item::POTION, 1), (item::GREAT_BALL, 1)]; assert_eq!(throw_slot(&mut world), Some(1)); world.bag = vec![(item::ULTRA_BALL, 1)]; assert_eq!(throw_slot(&mut world), Some(0)); world.bag = vec![(item::MASTER_BALL, 1)]; assert_eq!(throw_slot(&mut world), Some(0)); // No ball, or a stack of none, is no button. world.bag = vec![(item::POTION, 1)]; assert_eq!(throw_slot(&mut world), None); world.bag = vec![(item::POKE_BALL, 0)]; assert_eq!(throw_slot(&mut world), None); assert!(!on_the_pad(&mut world, MacroKind::ThrowBall)); // A trainer battle refuses a ball on the cartridge ("blocked the BALL!"), so it is off the pad. world.bag = vec![(item::POKE_BALL, 1)]; world.battle = Some((BattleKind::Trainer, true, false)); assert_eq!(throw_slot(&mut world), None); world.battle = Some((BattleKind::Wild, true, false)); assert_eq!(throw_slot(&mut world), Some(0)); // A full party is the limit worth stating: a catch would go to the PC box, and this crate has // no reviewed symbol for the box count, so the button leaves the pad rather than guessing. world.mons = (0..6).map(|slot| mon(slot, 20, 20, &[(33, 30)])).collect(); assert_eq!(throw_slot(&mut world), None, "six in the party"); world.mons.truncate(5); assert_eq!(throw_slot(&mut world), Some(0)); // And never outside a battle. let mut room = World::room(); room.bag = vec![(item::POKE_BALL, 1)]; assert_eq!(throw_slot(&mut room), None); assert!(!on_the_pad(&mut room, MacroKind::ThrowBall)); } /// Section 12.9: a ball is not thrown at a species the party already holds. /// /// **Live on rung 9**, 69 hours in Viridian Forest: `THROW BALL` was 28 of 183 macro starts, and /// the forest holds Caterpie, Weedle, Metapod, Kakuna and Pidgey -- the fly had caught its own and /// went on throwing at them. Every throw spends a ball, and a catch opens the nickname screen, /// which reads `Unknown` and needs a START the pad has no button for (row 14). /// /// The party is the caught set: the cartridge's own lifetime record, in the same internal species /// numbering the enemy is read in. `wPokedexOwned` is by Pokédex number and the conversion is in a /// ROM bank this crate cannot read, so it is not asked. #[test] fn throw_ball_refuses_a_species_the_party_already_holds() { // Two distinct internal species indices -- the forest's Weedle and Caterpie, whose exact // numbers nothing below depends on. let weedle = 0x70; let caterpie = 0x7b; let mut world = World::battle(); world.mons.truncate(1); world.mons[0].species = caterpie; world.bag = vec![(item::POKE_BALL, 5)]; // A species the party does not hold: the button is on the pad, as before. world.enemy = Some(EnemyMon { species: weedle, level: 6, hp: 20, max_hp: 20 }); assert_eq!(throw_slot(&mut world), Some(0)); assert!(on_the_pad(&mut world, MacroKind::ThrowBall)); // The one that is already in the party: off the pad, whatever the bag holds. world.enemy = Some(EnemyMon { species: caterpie, level: 6, hp: 20, max_hp: 20 }); assert_eq!(throw_slot(&mut world), None, "a Caterpie is already in the party"); assert!(!on_the_pad(&mut world, MacroKind::ThrowBall)); // Any party slot counts, not only the one that is out. world.mons.push(mon(1, 20, 20, &[(33, 30)])); world.mons[1].species = weedle; world.enemy = Some(EnemyMon { species: weedle, level: 6, hp: 20, max_hp: 20 }); assert_eq!(throw_slot(&mut world), None, "a Weedle is on the bench"); // A species the seam could not place leaves the button where it was: an unobservable // precondition is a guess, and this crate does not guess (section 13.1). world.enemy = None; assert_eq!(throw_slot(&mut world), Some(0), "no reading is not a refusal"); world.enemy = Some(EnemyMon { species: 0, level: 0, hp: 0, max_hp: 0 }); assert_eq!(throw_slot(&mut world), Some(0), "species 0 is not a species"); } #[test] fn throw_ball_opens_the_bag_and_moves_the_cursor_to_the_ball_by_reading_it() { let mut world = World::battle(); world.mons.truncate(1); // Two potions before the ball, so the ball's own bag index is 2 and the script has to move. world.bag = vec![(item::POTION, 1), (item::ANTIDOTE, 1), (item::POKE_BALL, 5)]; assert_eq!(throw_slot(&mut world), Some(2)); // A on ITEM opens the bag, a plain column of three. world.opens.push_back(Opens { list: List::BattleBag, cursor: 0, max: 2, grid: false }); assert_eq!(run(&mut world, MacroKind::ThrowBall).unwrap(), MacroAbort::Done); assert_eq!(world.cursor, 2, "the ball's own index, by watching where the cursor went"); assert!( world.pulses.contains(&buttons::DOWN), "it walked the list rather than counting presses: {:?}", world.pulses ); // The script stops at the confirmation: the throw animation, the shake count and the result // text are the between-turns `NEXT`'s, and a nickname prompt is the dialog's `NO`. assert_eq!(world.pulses.last(), Some(&buttons::A)); } /// Section 12.11: a room whose one way out every ledger is resting still offers it. /// /// **What was live** (2026-09-22, rung 10, the release container's own checkpoint): the fly on /// **map 0x35, the Pewter museum's upper floor** -- fourteen blocks by eight, one warp at (7, 7) /// down to the floor below (0x34), two signs and three exhibits. The reproduction is in /// `infra/docs/macros-traps.md`; the shape of it is that every candidate list on that map empties: /// /// - `geography` has no row for the museum, so `next_hop` from it answers `None` and /// `GO OBJECTIVE` has nothing to aim at -- the objective itself is fine (map 0x36, the gym /// leader, rung 11's BOULDER BADGE); /// - the three exhibits and two signs are *reached* by `GO NPC` and `GO ITEM`, which retires them /// for the session; /// - the four unstood tiles are walked or excluded, and `GO FRONTIER` empties for the window; /// - the one warp out is classified a **passage** and not an exit -- it is a staircase -- and /// `unexcluded_exits` drops it while the blocked ledger rests it. /// /// That left `MENU` and nothing else, and `MENU` opened the start menu whose `BACK` closed it /// again: 82 starts each in thirty minutes. With `MENU` gone the same state has to deal a walk, and /// the walk is the staircase **although the ledger is resting it** -- a target the ledger has /// parked is still the only place to go. #[test] fn a_room_whose_only_way_out_the_ledger_rests_still_offers_it() { let mut world = World::room(); // One staircase, no front door: the museum's upper floor, and Red's bedroom, and every other // map whose only way anywhere is a passage. world.warps = vec![Warp { x: 7, y: 1, destination_warp: 2, destination_map: 0x26 }]; world.seen_maps.insert(0x26); world.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect(); assert_eq!( path::exits(&mut world).iter().map(|exit| exit.way).collect::>(), vec![Way::Passage], "a staircase and no front door" ); // With the staircase unexcluded the pad is the ordinary indoor one. assert!(on_the_pad(&mut world, MacroKind::GoWarp)); // Now rest it, which is what a refused walk does for ten brain minutes. world.targets.record_blocked(world.map, TargetKey::Exit(ExitId::Warp(0))); assert!(super::palette::stranded(&mut world), "nothing else on this floor to walk to"); assert!( on_the_pad(&mut world, MacroKind::GoWarp), "the resting staircase comes back as the last resort" ); let pad = pad_of(&mut world); assert_eq!(pad, ["GO WARP"], "and it is the whole pad: {pad:?}"); // And it is *only* a last resort: one unstood tile and the resting staircase goes away again, // because "the nearest door, once per hold" is row 2's own loop. world.stood.remove(&Tile::new(0, 0)); assert!(!super::palette::stranded(&mut world)); assert!(!on_the_pad(&mut world, MacroKind::GoWarp)); assert!(on_the_pad(&mut world, MacroKind::GoFrontier)); } /// Section 12.11, stated at the pad: no overworld pad is one button that undoes itself. /// /// The test the museum loop would have failed. `MENU` on the overworld and `BACK` on the start /// menu are a pair across two scenes, so a per-pad rule cannot see it -- what can is that `MENU` /// is on no pad at all, and that what is left when every ledger is against the map is a *walk*. #[test] fn an_overworld_pad_is_never_one_button_that_undoes_itself() { // The museum shape, indoors, and the town shape, outdoors: both stranded, both dealt a walk. let mut indoors = World::ground_floor(); indoors.seen_maps.insert(0x00); indoors.seen_maps.insert(0x26); indoors.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect(); for exit in path::exits(&mut indoors) { indoors.targets.record_blocked(indoors.map, TargetKey::Exit(exit.id)); } let mut outdoors = viridian(); outdoors.warps.clear(); outdoors.connections = Connections { north: true, south: false, east: false, west: false }; outdoors.seen_maps.insert(maps::ROUTE_2); outdoors.areas.insert((Amenity::Mart, maps::VIRIDIAN_CITY)); outdoors.areas.insert((Amenity::Center, maps::VIRIDIAN_CITY)); outdoors.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect(); outdoors.targets.record_blocked(outdoors.map, TargetKey::Exit(ExitId::Edge(Edge::North))); for world in [&mut indoors, &mut outdoors] { let pad = pad_of(world); assert!(!pad.is_empty(), "an overworld with a way out deals something"); assert!(!pad.contains(&"MENU"), "MENU is on no pad: {pad:?}"); assert!( pad.iter().any(|name| name.starts_with("GO ")), "what is left is a walk rather than a screen to open and close: {pad:?}" ); } } /// Section 12.11: the move list deals `BACK` only where the moves can be read. /// /// The v0.4.3 residual (`infra/docs/macros-traps.md`): `BACK` was 263 of 797 macro starts, every /// one over an open move list, and 142 of the run's `NEXT` starts were on a move list whose cursor /// the seam could not place. A move list the seam cannot read the battler for binds no `MOVE n` at /// all, so its pad was `BACK` alone -- and the only thing that button does is close the list that /// `MOVE 1` on the menu underneath had just opened. That is 12.10's pair with `MOVE 1` in `NEXT`'s /// place. `MOVE 1` alone confirms wherever the cursor stands, which is the press that ends a turn. #[test] fn the_move_list_deals_back_only_where_the_moves_can_be_read() { let mut world = World::battle(); world.list = List::Moves(3); world.grid = false; world.cursor_max = 2; assert_eq!(pad_of(&mut world), ["BACK", "MOVE 1", "MOVE 2", "MOVE 3"]); // The battler the seam cannot place: no active slot, so `battle.own` is `None`. world.active = None; let pad = pad_of(&mut world); assert_eq!(pad, ["MOVE 1"], "one button, and it ends the turn: {pad:?}"); assert!(move_slot_bound(&mut world, MacroKind::Move1)); assert!(!move_slot_bound(&mut world, MacroKind::Move2)); // And it really presses: the cursor is confirmed where it stands, which is Struggle's own // path (row 30a) and the only reading available here. assert_eq!(run(&mut world, MacroKind::Move1).unwrap(), MacroAbort::Done); assert_eq!(world.pulses.last(), Some(&buttons::A)); } /// Section 12.11: Red's battle menu is two columns, so its order is FIGHT, ITEM, PKMN, RUN. /// /// The screen reads `FIGHT PKMN` over `ITEM RUN` and the game's own index does not: it is the row /// inside the column the cursor is in, plus two for the right column. `battle_entry` had `PKMN` 1 /// and `ITEM` 2, which is the row-major reading of the picture, so **every macro that opened the /// bag opened the party list and every macro that opened the party list opened the bag**. /// /// **Surveyed on the cartridge** (`infra/docs/macros-traps.md`): a `THROW BALL` aiming at 2 walked /// the cursor to `wTopMenuItemX` 15 / `wCurrentMenuItem` 0, pressed A, and the party list opened -- /// `wTopMenuItemY` 1, `wTopMenuItemX` 0, `wListMenuID` `$02` -- with the game writing /// `wCurrentMenuItem` 2 on the frame after the press. On v0.4.3 `THROW BALL` was 63 starts and 63 /// `blocked`, and `SWITCH` 15 of them: never the macro's own list, always the other one. #[test] fn the_battle_menus_two_columns_put_item_under_fight_and_pkmn_beside_it() { use super::cartridge::battle_entry; assert_eq!( [battle_entry::FIGHT, battle_entry::ITEM, battle_entry::PKMN, battle_entry::RUN], [0, 1, 2, 3], "the left column is FIGHT then ITEM, the right is PKMN then RUN" ); // And the seam reads the same order back off the fake's own geometry: DOWN moves one inside a // column, RIGHT moves two across. let mut world = World::battle(); let menu = |world: &mut World| super::palette::battle_menu(world); assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::FIGHT }); world.on_pulse(buttons::DOWN); assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::ITEM }); world.on_pulse(buttons::UP); world.on_pulse(buttons::RIGHT); assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::PKMN }); world.on_pulse(buttons::DOWN); assert_eq!(menu(&mut world), BattleMenu::Main { cursor: battle_entry::RUN }); } /// Section 12.11: a cursor step waits for the list it was built for. /// /// **Measured on the cartridge, v0.4.3** (`infra/docs/macros-traps.md`): `THROW BALL` was **63 /// starts and 63 `blocked`**, mean sixty-nine frames -- which is the cursor to ITEM, the A that /// confirms it, the twenty settle frames, and then a refusal on the very next frame. The bag had /// not drawn yet, so `listing` still answered for the battle *menu*: four entries, `max` 3. The /// ball's own bag index was above that, so the step read "off the end of the list" and gave up at /// once -- and where the index was inside it, the step pressed UP and LEFT at the battle menu /// instead, which is the blind pressing section 4 forbids. #[test] fn throw_ball_waits_for_the_bag_rather_than_reading_the_menu_it_came_from() { let mut world = World::battle(); world.mons.truncate(1); // Five items with the ball last, so its bag index is 4 -- above the battle menu's `max` of 3, // which is the number the old step compared it against. world.bag = vec![ (item::POTION, 1), (item::ANTIDOTE, 1), (item::REPEL, 1), (item::POTION, 1), (item::POKE_BALL, 5), ]; assert_eq!(throw_slot(&mut world), Some(4)); // The bag takes longer to draw than the script's twenty settle frames, which is the cartridge's // own timing and the whole of the trap. world.opens_draw_in = 40; world.opens.push_back(Opens { list: List::BattleBag, cursor: 0, max: 4, grid: false }); assert_eq!(run(&mut world, MacroKind::ThrowBall).unwrap(), MacroAbort::Done); assert_eq!(world.cursor, 4, "the ball's own bag index, by reading the bag's cursor"); assert_eq!(world.pulses.last(), Some(&buttons::A)); // Nothing was pressed at the menu it came from while the bag was drawing: the presses are the // one that chose ITEM and then the bag's own. assert_eq!( world.pulses.iter().filter(|mask| **mask == buttons::UP).count(), 0, "no blind press at the list it had already answered: {:?}", world.pulses ); // And a list that never opens is `blocked` rather than pressed at blind. let mut never = World::battle(); never.mons.truncate(1); never.bag = vec![(item::POKE_BALL, 5)]; assert_eq!(run(&mut never, MacroKind::ThrowBall).unwrap(), MacroAbort::Blocked); } /// Row 37 of `infra/docs/macros-traps.md`: a tile the cartridge pushes the fly off is not a tile /// to stand on, and the ground beside a villager is not the villager's fault. /// /// **The measurement.** From the release container's Viridian checkpoint, twenty brain minutes: /// **53,266 of 54,377 text-box frames on one tile**, (19, 9) of Viridian City, 991 of them /// answered `YES`. `ViridianCityCheckGotPokedexScript` fires on *every frame* the fly stands there /// without the Pokédex — "This is private property!", then it walks the player down — and (19, 9) /// is one of the four tiles `approach` offers around the sleeping old man at (18, 9). So `GO NPC` /// walked onto it, the fly advanced the box, the walk went back. /// /// The blocked ledger could not close it: it is keyed on the *target*, so it excluded the old man /// for ten brain minutes and said nothing about the ground, and the window reopened. #[test] fn a_tile_the_cartridge_pushes_the_fly_off_is_not_a_tile_to_walk_to() { // The shape of the Viridian case: a person with the fly on one side and a scripted tile on // the other. The fly starts away from both so nothing is excluded for being already faced. let mut world = World::room().at(3, 6); world.facing = Facing::Up; world.npcs = vec![Npc { slot: 1, picture: 1, x: 3, y: 3, facing: Facing::Down }]; // All four sides of the villager are offered, as they always were. let before: Vec = untalked_people(&mut world) .into_iter() .map(|(tile, _)| tile) .collect(); assert_eq!(before, vec![Tile::new(3, 3)], "the person is the target"); assert_eq!(run(&mut world, MacroKind::GoNpc).unwrap(), MacroAbort::Done); assert_eq!(world.player.distance(Tile::new(3, 3)), 1, "it stood beside the villager"); // Now make the tile it chose a scripted one and let the ledger learn it. The walk still has // three other sides, so the villager is not written off for the ground beside him. let chosen = world.player; let mut world = World::room().at(3, 6); world.facing = Facing::Up; world.npcs = vec![Npc { slot: 1, picture: 1, x: 3, y: 3, facing: Facing::Down }]; world.pushes.insert(chosen); assert!(on_the_pad(&mut world, MacroKind::GoNpc), "the villager is still worth walking to"); assert_eq!(run(&mut world, MacroKind::GoNpc).unwrap(), MacroAbort::Done); assert_ne!(world.player, chosen, "and it stood on one of his other sides"); assert_eq!(world.player.distance(Tile::new(3, 3)), 1); // With every side of him scripted there is nothing to walk to and the button leaves the pad, // which is section 12's "a precondition failure means the button is not on the pad". for facing in FACINGS { if let Some(side) = Tile::new(3, 3).step(facing) { world.pushes.insert(side); } } assert!(!on_the_pad(&mut world, MacroKind::GoNpc)); // The route will not cross one either, which is the other half of the fix: excluding a // scripted tile as a *goal* left the A* routing across it on the way to somewhere else, and // the script fires on any frame the fly stands there. Measured: (19, 9) went from 53,266 // text-box frames in twenty brain minutes to 12,919 on the goal fix alone. let mut through = World::room().at(0, 3); through.walls = (0..8).filter(|y| *y != 3).map(|y| Tile::new(1, y)).collect(); assert!( path::route(&mut through, &[Tile::new(3, 3)]).is_some(), "a corridor through (1, 3) is walkable" ); through.pushes.insert(Tile::new(1, 3)); assert!( path::route(&mut through, &[Tile::new(3, 3)]).is_none(), "and it is a wall once the cartridge has pushed the fly off it" ); // And the frontier will not walk onto one either: the tile is not new ground, it is a script. let mut frontier = World::room().at(3, 3); frontier.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect(); frontier.stood.remove(&Tile::new(0, 0)); assert!(!super::palette::frontier_aims(&mut frontier).is_empty()); frontier.pushes.insert(Tile::new(0, 0)); frontier.pushes.insert(Tile::new(0, 1)); frontier.pushes.insert(Tile::new(1, 0)); assert!( super::palette::frontier_aims(&mut frontier).is_empty(), "neither the bordering tiles nor the far fallback offers a scripted tile" ); } // --------------------------------------------------------------------------------------------- // Row 57: a last resort refused from here is not dealt again from here // --------------------------------------------------------------------------------------------- /// Pewter City as the live pad found it: the fly fenced into a corner of the town, the /// frontier marked, every person and sign accounted for, the errands paid -- and the only way /// out anything aims at, the gym's door, on the far side of the fence. fn fenced_in_pewter() -> World { let mut world = World::room().at(3, 3); world.map = maps::PEWTER_CITY; world.size = MapSize { width: 20, height: 12 }; // The fence: a wall the height of the map, with the fly on the near side of it. for y in 0..12 { world.walls.insert(Tile::new(10, y)); } world.warps = vec![Warp { x: 15, y: 3, destination_warp: 0, destination_map: maps::PEWTER_GYM }]; world.seen_maps.insert(maps::PEWTER_GYM); world.objective = Some(Objective { map: maps::PEWTER_GYM, tile: None, warp: None, edge: None, target: Some(PlaceKind::Person), }); world.areas.insert((Amenity::Mart, maps::PEWTER_CITY)); world.areas.insert((Amenity::Center, maps::PEWTER_CITY)); world.exhausted.insert(maps::PEWTER_CITY); world } #[test] fn a_way_out_refused_from_here_is_not_dealt_again_from_here() { // Row 57, live on rung 10 for two hours: the pad was `GO ROUTE` alone, `refused` every 800 // brain ms, nothing pressed. `ways`'s last resort deliberately ignores the blocked ledger, so // the `no route` refusal -- which writes what it could not reach to that ledger -- could not // take the button off the pad, and the same refusal re-stamped the gym door every hold, which // kept `GO OBJECTIVE`'s only goal excluded for ever. let mut world = fenced_in_pewter(); let door = TargetKey::Exit(ExitId::Warp(0)); let names = |world: &mut World| -> Vec<&'static str> { let scene = world.scene(); plan::plan_for(scene, world).slots.iter().flatten().map(|spec| spec.name).collect() }; assert_eq!(names(&mut world), ["GO OBJECTIVE", "GO ROUTE"], "the door, two ways"); assert_eq!( run(&mut world, MacroKind::GoObjective).map_err(|refused| refused.reason), Err(Refusal::NoRoute) ); assert!(world.targets.blocked(world.map, door), "the door is excluded for the window"); assert_eq!(names(&mut world), ["GO ROUTE"], "and the last resort still deals it"); assert_eq!( run(&mut world, MacroKind::GoRoute).map_err(|refused| refused.reason), Err(Refusal::NoRoute), "the route search agrees with the fence" ); // The trap: before row 57 the same button was dealt again, from the same tile, on the next // hold, for ever. assert!( names(&mut world).is_empty(), "a button refused from this tile is not dealt again from it: {:?}", names(&mut world) ); // It is a fact about *here*, not a retirement: standing anywhere else deals it again ... world.player = Tile::new(3, 4); assert_eq!(names(&mut world), ["GO ROUTE"]); // ... and so does the window closing on the same tile. world.player = Tile::new(3, 3); assert!(names(&mut world).is_empty()); world.targets.clock(BLOCKED_MINUTES_DEFAULT * 60_000.0 + 1.0); assert!(names(&mut world).contains(&"GO ROUTE"), "{:?}", names(&mut world)); } #[test] fn a_last_resort_that_cannot_reach_the_objectives_door_takes_a_way_out_it_can_reach() { // Row 57's pocket had a way out: the road east, three tiles from the fly and resting in the // blocked window. The last resort ignores that window but *narrows* to the ways toward the // objective, and the only one was the gym's door on the far side of the fence -- so the // route search refused, and the reachable road was never tried. The narrowing is a // preference; when the route search cannot honour it, the rest of the last resort is the // walk's to take, nearest reachable first. let mut world = fenced_in_pewter(); world.connections = Connections { north: false, south: true, east: false, west: false }; world.seen_maps.insert(maps::ROUTE_2); let south = TargetKey::Exit(ExitId::Edge(Edge::South)); world.targets.record_blocked(world.map, south); world.targets.record_blocked(world.map, TargetKey::Exit(ExitId::Warp(0))); assert!(on_the_pad(&mut world, MacroKind::GoRoute), "the last resort deals it"); let started_at = world.player; let outcome = run(&mut world, MacroKind::GoRoute); assert!(outcome.is_ok(), "the road it can reach is walked, not refused: {outcome:?}"); assert!( world.player.y > started_at.y, "south, toward the way out on this side of the fence: {:?}", world.player ); // The order the real brain pressed them in, measured on the seeded pocket: `GO ROUTE` first, // while the door is still the second tier's answer rather than the last resort's. That refusal // teaches the ledger the door, so it is not held against the tile -- the next deal is the last // resort, and its walk goes where the first could not. let mut world = fenced_in_pewter(); world.connections = Connections { north: false, south: true, east: false, west: false }; world.seen_maps.insert(maps::ROUTE_2); world.targets.record_blocked(world.map, south); world.exhausted.insert(world.map); world.objective = Some(Objective { map: maps::PEWTER_GYM, tile: None, warp: None, edge: None, target: Some(PlaceKind::Person), }); assert_eq!( run(&mut world, MacroKind::GoRoute).map_err(|refused| refused.reason), Err(Refusal::NoRoute), "the door, through the second tier, beyond the fence" ); assert!(on_the_pad(&mut world, MacroKind::GoRoute), "a refusal that taught the ledger is not held"); let started_at = world.player; assert!(run(&mut world, MacroKind::GoRoute).is_ok(), "the last resort walks where it can"); assert!(world.player.y > started_at.y, "{:?}", world.player); } #[test] fn an_escorted_walk_walls_the_tile_it_reached_not_the_one_it_set_out_from() { // Row 57's other half. Pewter City's youngster takes the joypad on four tiles by the road // east and walks the fly to the gym. A `GO ROUTE` that set out from the town's south entrance // and reached one of those tiles wrote the *south entrance* into the pushed ledger -- a wall // with no window, twenty-six tiles from where the script fired -- and a dozen of those fenced // the fly into a pocket. The tile the walk had reached is where the cartridge took over. let mut world = World::room().at(3, 6); world.map = maps::PEWTER_CITY; world.connections = Connections { north: true, south: false, east: false, west: false }; // Three tiles walked north, then the script: the scene changes with the game driving the fly. world.scripted_at = Some(52); world.switch = Some((52, Scene::Dialog)); let mut machine = MacroMachine::new(1); let _ = run_with(&mut machine, &mut world, MacroKind::GoRoute); // 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. #[test] fn a_scripted_push_back_records_the_tile_it_happened_on() { let mut world = World::ground_floor().at(3, 3); world.facing = Facing::Up; world.npcs = vec![Npc { slot: 1, picture: 1, x: 3, y: 2, facing: Facing::Down }]; // The cartridge takes the joypad two frames in, which is what the Viridian gate and the // private-property tile both do (`MacroState::scripted`). world.scripted_at = Some(2); world.switch = Some((3, Scene::Dialog)); let mut machine = MacroMachine::new(1); let _ = run_with(&mut machine, &mut world, MacroKind::Talk); 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!( world.pushes.iter().copied().collect::>(), vec![Tile::new(3, 3)], "the tile the macro was standing on, not the person it was facing" ); } 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)); } // --------------------------------------------------------------------------------------------- // 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"); }