flybrain/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs
acamilo 57d9bdff5a macros: Red's battle menu is two columns, so ITEM is under FIGHT and PKMN beside it
THROW BALL was 63 starts and 63 blocked on v0.4.3 and SWITCH 15 of them, and the
reason is neither macro's own script. The screen reads FIGHT PKMN over ITEM RUN
and the game's index does not: 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. battle_entry had PKMN 1 and ITEM 2 -- the row-major
reading of the picture -- so every macro that meant to open the bag opened the
party list and every macro that meant to open the party list opened the bag.

Surveyed on the cartridge from the rung-9 checkpoint: 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.

The fake had the same mistake in its own geometry, so it could not have caught
it: its two-by-two moved row-major. It is column-major now, like the cartridge.
2026-09-22 08:50:40 +00:00

4567 lines
206 KiB
Rust

//! 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, 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, errand, healthiest_other, heal_goals,
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<Tile>,
/// Tiles the predicate cannot answer for, which is agent A's windowed `Unknown`.
unseen: BTreeSet<Tile>,
warps: Vec<Warp>,
connections: Connections,
npcs: Vec<Npc>,
signs: Vec<Sign>,
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<Mon>,
active: Option<u8>,
/// 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<EnemyMon>,
money: u32,
bag: Vec<(u8, u8)>,
stock: Vec<u8>,
/// Tiles the game lets the player talk *over*: a mart's or a centre's counter.
counters: BTreeSet<Tile>,
/// 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<Tile>,
visited: BTreeSet<ExitId>,
/// Tiles of this map the run has stood on, for `GO FRONTIER` and the plan's "untalked" test.
stood: BTreeSet<Tile>,
/// Maps the run has been on, for `GO ROUTE`'s unvisited interior.
seen_maps: BTreeSet<u8>,
/// Where the ladder's next rung is, for `GO OBJECTIVE`.
objective: Option<Objective>,
/// What this session has talked to, for `GO NPC`, `GO ITEM` and `TALK`.
talked: BTreeSet<TalkTarget>,
/// 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<Opens>,
/// 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<u32>,
/// 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<u32>,
/// Every completed pulse, in order.
pulses: Vec<u8>,
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(),
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(),
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,
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
}
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<Player> {
Some(Player { map: self.map, x: self.player.x, y: self.player.y, facing: self.facing })
}
fn map_size(&mut self) -> Option<MapSize> {
Some(self.size)
}
fn party(&mut self) -> Party {
Party { mons: self.mons.clone(), active: self.active }
}
fn battle(&mut self) -> Option<Battle> {
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<StartMenu> {
(self.list == List::Start)
.then(|| StartMenu { cursor: self.cursor_state(), items: self.cursor_max + 1 })
}
fn shop(&mut self) -> Option<Shop> {
match self.list {
List::Shop(screen) => Some(Shop { screen, cursor: self.cursor_state() }),
_ => None,
}
}
fn pc(&mut self) -> Option<Pc> {
(self.list == List::Pc).then(|| Pc { cursor: self.cursor_state() })
}
fn money(&mut self) -> u32 {
self.money
}
fn bag(&mut self) -> Vec<super::state::BagItem> {
self.bag.iter().map(|(id, count)| super::state::BagItem { id: *id, count: *count }).collect()
}
fn npcs(&mut self) -> Vec<Npc> {
self.npcs.clone()
}
fn signs(&mut self) -> Vec<Sign> {
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<Warp> {
self.warps.clone()
}
fn connections(&mut self) -> Connections {
self.connections
}
}
impl MacroState for World {
fn scripted(&mut self) -> bool {
self.scripted
}
fn shop_stock(&mut self) -> Vec<u8> {
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 objective(&mut self) -> Option<Objective> {
self.objective
}
}
fn direction_of(mask: u8) -> Option<Facing> {
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<Facing> {
(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<MacroAbort, MacroRefused> {
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<MacroAbort, MacroRefused> {
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<MacroAbort, MacroRefused> {
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);
}
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.
while let Some((map, target)) = machine.take_blocked() {
world.targets.record_blocked(map, target);
}
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);
}
if let Some((map, target)) = machine.take_talked() {
assert_eq!(map, world.map);
world.talked.insert(target);
}
Ok(machine.outcome().expect("a finished macro has an outcome").1)
}
/// 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();
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`.
assert_eq!(names(&palette), ["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.
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 ANTIDOTE", "BUY REPEL", "LEAVE"]
);
// Money is the other half, per item: 150 buys an Antidote and nothing else.
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<Tile> =
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<Tile> =
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<u8> = 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<Tile> = 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<Tile> = 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<_>>(), 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;
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<Exit> = 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<_>>(),
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;
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 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_answers_no_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.
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, so the scene is a dialog and the pad is the dialog's.
world.scene = Scene::Dialog;
let (dialog, no) = pick(&mut world, MacroKind::No);
// Both answers are on the pad, whatever the box is: there is no WRAM observable for "a choice
// is open" (section 12.2, row 15).
assert!(names(&dialog).contains(&"YES"), "{:?}", names(&dialog));
assert!(names(&dialog).contains(&"NO"), "{:?}", 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);
}
world.scene = Scene::Overworld;
machine.observe_frame(&mut world);
assert_eq!(machine.take_talked(), None, "a no is not a conversation had");
assert!(on_the_pad(&mut world, MacroKind::Talk), "the offer stands");
}
#[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));
assert_eq!(run(&mut world, MacroKind::GoRoute), Ok(MacroAbort::Done));
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<_>>(),
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<_>>(),
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 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<_>>(), 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));
// 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;
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, `ct150-rank10` 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<_>>(),
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<Tile> = 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"
);
}
/// 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);
let pushed = machine.take_pushed();
assert_eq!(
pushed,
Some((world.map, Tile::new(3, 3))),
"the tile the macro was standing on, not the person it was facing"
);
}
mod map_aware;
mod shop_purchase;