macros: MENU is off the overworld pad, and the way out is the never-empty rule

Rung 10, thirty brain minutes inside the Pewter museum's upper floor (map 0x35):
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,
and that scene's pad is CLOSE / CONFIRM / BACK -- so 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, and section 12.2's trap by
definition: a precondition satisfied wherever the fly stands and a macro that
completes without moving.

Nothing in the macro vocabulary uses the start menu for anything, so MENU comes
off scene_set's overworld row entirely rather than being narrowed. It stays a
type, a population, a tag and a script, so the roles, the channel order and
--print-compatibility are untouched, and the fly's raw START still opens the
start menu in macros mode.

MENU was also what made an empty overworld pad impossible. That guarantee moves
to the way out: `ways` gains one last resort for a *room*, the one GO ROUTE has
had outdoors since 13.1, so a map that offers nothing else at all still offers
its door or its staircase -- ignoring the blocked window, because a target the
ledger is resting is still the only place to go. The museum's staircase is a
passage and not an exit, which is why the old tier 3 could not answer for it.

A map with no way out at all is now a genuinely empty pad. No map in Red is
that, it is asserted as the named residual, and game.padEmptyMs reports it.
This commit is contained in:
acamilo 2026-09-22 08:06:06 +00:00
parent 928d66bd8c
commit 3807fff422
2 changed files with 272 additions and 42 deletions

View file

@ -484,8 +484,18 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
Scene::Menu => vec![Close, Confirm, Back], Scene::Menu => vec![Close, Confirm, Back],
// Section 9.1's split, plus section 13's errands and centre. Indoors the ways out of a // Section 9.1's split, plus section 13's errands and centre. Indoors the ways out of a
// room are the building's door and its passages; outdoors there is no building to leave. // room are the building's door and its passages; outdoors there is no building to leave.
// `MENU` is unconditional and last, which is what makes an empty overworld pad impossible //
// (section 13.1, row 18). // **`MENU` is on no pad** (section 12.11). It was here as the unconditional button that
// made an empty overworld pad impossible, and that is exactly what made it a trap: opening
// the start menu changes nothing in the world, so the macro completes where the fly stands
// -- section 12.2's rule -- and the scene it opens deals `CLOSE` and `BACK`, which close it
// again. Live on rung 10, thirty minutes inside the Pewter museum's upper floor: `MENU` 82
// starts, `BACK` 82, `GO FRONTIER` 8, the log alternating `MENU start/done, BACK
// start/done`. Nothing in the macro vocabulary uses the start menu for anything -- there is
// no SAVE macro and no POKéDEX macro -- so there is nothing behind the button worth
// pressing it for. What keeps this pad from being empty instead is the way out, which
// [`ways`] offers regardless of the ledgers when nothing else on the map is worth walking
// to.
Scene::Overworld => { Scene::Overworld => {
let mut set = vec![GoObjective]; let mut set = vec![GoObjective];
if outdoors_now(state) { if outdoors_now(state) {
@ -497,7 +507,7 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
if inside_center(state) { if inside_center(state) {
set.push(Heal); set.push(Heal);
} }
set.extend([GoShop, GoHeal, GoItem, GoNpc, GoFrontier, Talk, Menu]); set.extend([GoShop, GoHeal, GoItem, GoNpc, GoFrontier, Talk]);
set set
} }
// Section 12: "Forced switch: SWITCH" -- plus the press that advances a battle, because // Section 12: "Forced switch: SWITCH" -- plus the press that advances a battle, because
@ -579,7 +589,15 @@ pub fn precondition(kind: MacroKind, state: &mut dyn MacroState) -> bool {
// (only when facing something untalked)"). A tile ahead with nothing on it is not a // (only when facing something untalked)"). A tile ahead with nothing on it is not a
// reason to press A, and a shelf that has been read is not a reason to read it again. // reason to press A, and a shelf that has been read is not a reason to read it again.
MacroKind::Talk => facing_untalked(state), MacroKind::Talk => facing_untalked(state),
// Unconditional: opening the start menu is always available. // The start menu opens from anywhere, and that is exactly why `MENU` is on no pad:
// "the precondition is satisfied wherever the fly stands" is section 12.2's trap, and a
// macro whose whole effect is a screen its own scene's `BACK` closes again is 12.10's
// pair one scene wider (section 12.11). The refusal belongs at the **dealer** and not
// here, because this arm is a true fact about the macro and [`scene_set`] is where the
// reason lives: nothing in the vocabulary uses the start menu, so there is nothing behind
// the button to press it for -- and the day a SAVE macro exists, one row changes.
// `MENU` stays a type, a population, a tag and a script, so the roles, the channel order
// and `--print-compatibility` are untouched.
MacroKind::Menu => true, MacroKind::Menu => true,
// Advancing text, answering a prompt and backing out never need anything. // Advancing text, answering a prompt and backing out never need anything.
MacroKind::Next MacroKind::Next
@ -985,7 +1003,17 @@ pub fn ways(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
let all = unexcluded_exits(state, way); let all = unexcluded_exits(state, way);
// Tier 3, and only where a map would otherwise be impossible to leave: see the doc comment. // Tier 3, and only where a map would otherwise be impossible to leave: see the doc comment.
match way { match way {
Way::Exit => all, // A room still has to be leavable, and since section 12.11 that holds even while the
// ledgers are resting its one door: `unexcluded_exits` is emptied by the blocked window,
// by the rung's own target being on this map (row 29) and by an unfaced counter, and with
// `MENU` off the pad an emptied way out is an overworld with nothing on it at all.
Way::Exit => {
if all.is_empty() {
last_resort(state, way)
} else {
all
}
}
// A staircase the run has already been up is exploration already done, and the same bounce // A staircase the run has already been up is exploration already done, and the same bounce
// `GO ROUTE` had: up, straight back down, up again, once per hold. The exception is the map // `GO ROUTE` had: up, straight back down, up again, once per hold. The exception is the map
// whose only way anywhere *is* a passage -- Red's bedroom, the upper floor of any house -- // whose only way anywhere *is* a passage -- Red's bedroom, the upper floor of any house --
@ -993,6 +1021,8 @@ pub fn ways(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
Way::Passage => { Way::Passage => {
if path::exits(state).iter().any(|exit| exit.way == Way::Exit) { if path::exits(state).iter().any(|exit| exit.way == Way::Exit) {
Vec::new() Vec::new()
} else if all.is_empty() {
last_resort(state, way)
} else { } else {
all all
} }
@ -1010,19 +1040,31 @@ pub fn ways(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
// //
// It also ignores the blocked window, which nothing else does: a target the ledger is // It also ignores the blocked window, which nothing else does: a target the ledger is
// resting is still the only place to go. // resting is still the only place to go.
Way::Route => { Way::Route => last_resort(state, way),
if stranded(state) {
let every: Vec<Exit> =
path::exits(state).into_iter().filter(|exit| exit.way == way).collect();
let toward = toward_objective(state, &every);
if toward.is_empty() { every } else { toward }
} else {
Vec::new()
}
}
} }
} }
/// Every way out of this kind, ignoring the ledgers, when the map offers nothing else at all.
///
/// Section 13.1's never-empty rule, and since section 12.11 it is what the rule *rests* on: the
/// overworld has no unconditional button any more, so the pad of a map with every ledger against it
/// is this list or nothing. Guarded by [`stranded`], which is built out of [`exit_tiers`] rather
/// than [`ways`] so that asking "is the fly stranded" cannot recurse into the answer it decides.
///
/// It ignores the blocked window, which nothing else does: a target the ledger is resting is still
/// the only place to go. And it is a last resort rather than a tier -- with anything else on the
/// pad it stays off, because "all of them, nearest" once per hold is row 2's own loop, measured as
/// two hours seventeen in and out of one house door.
fn last_resort(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
if !stranded(state) {
return Vec::new();
}
let every: Vec<Exit> =
path::exits(state).into_iter().filter(|exit| exit.way == way).collect();
let toward = toward_objective(state, &every);
if toward.is_empty() { every } else { toward }
}
/// The exits of the current map of one kind that no ledger excludes. /// The exits of the current map of one kind that no ledger excludes.
fn unexcluded_exits(state: &mut dyn MacroState, way: Way) -> Vec<Exit> { fn unexcluded_exits(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
if !objective_targets(state).is_empty() { if !objective_targets(state).is_empty() {
@ -1053,8 +1095,10 @@ fn unexcluded_exits(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
/// Section 13.1's pad-empty audit. Deliberately built out of [`exit_tiers`] rather than [`ways`], /// Section 13.1's pad-empty audit. Deliberately built out of [`exit_tiers`] rather than [`ways`],
/// so that asking "is the fly stranded" cannot recurse into the fallback the answer decides. /// so that asking "is the fly stranded" cannot recurse into the fallback the answer decides.
/// ///
/// `MENU` is unconditional, so an overworld pad is never literally empty; what this measures is /// What this measures is the state the operator saw on stream: a map where nothing the pad offers
/// the state the operator saw on stream, where the pad has a button that cannot move the fly anywhere. /// can move the fly anywhere. It used to be the weaker claim -- `MENU` was unconditional, so the
/// pad was never *literally* empty, only useless -- and since section 12.11 took `MENU` off the
/// overworld it is the literal one, which is why [`last_resort`] is what answers it.
pub fn stranded(state: &mut dyn MacroState) -> bool { pub fn stranded(state: &mut dyn MacroState) -> bool {
objective_goals(state).is_empty() objective_goals(state).is_empty()
&& amenity_goals(state, Amenity::Mart).is_empty() && amenity_goals(state, Amenity::Mart).is_empty()

View file

@ -685,7 +685,19 @@ fn run_with(
kind: MacroKind, kind: MacroKind,
) -> Result<MacroAbort, MacroRefused> { ) -> Result<MacroAbort, MacroRefused> {
let (palette, slot) = pick(world, kind); let (palette, slot) = pick(world, kind);
if let Err(refused) = machine.start(&palette, slot, world) { 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` // 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 // refusal earns blocked entries and presses nothing, so nothing else would collect them
// (`PokemonPalette::start`). // (`PokemonPalette::start`).
@ -726,6 +738,19 @@ fn run_with(
Ok(machine.outcome().expect("a finished macro has an outcome").1) 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) { fn pick(world: &mut World, kind: MacroKind) -> (Palette, MacroId) {
let scene = world.scene(); let scene = world.scene();
// The shipping mode is `PaletteMode::Plan` (`MacroMode::Macros` in `flysim::snapshot`), which // The shipping mode is `PaletteMode::Plan` (`MacroMode::Macros` in `flysim::snapshot`), which
@ -781,7 +806,8 @@ fn the_indoor_overworld_row_is_section_nine_ones_row() {
let palette = Palette::for_scene(Scene::Overworld, &mut world); let palette = Palette::for_scene(Scene::Overworld, &mut world);
assert_eq!( assert_eq!(
names(&palette), names(&palette),
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "TALK", "MENU"] ["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "TALK"],
"and no `MENU`, which section 12.11 took off this row"
); );
} }
@ -794,7 +820,7 @@ fn the_outdoor_overworld_row_has_the_route_and_no_passage_in_it() {
world.connections.south = true; world.connections.south = true;
world.npcs = vec![Npc { slot: 1, picture: 1, x: 4, y: 3, facing: Facing::Down }]; world.npcs = vec![Npc { slot: 1, picture: 1, x: 4, y: 3, facing: Facing::Down }];
let palette = Palette::for_scene(Scene::Overworld, &mut world); let palette = Palette::for_scene(Scene::Overworld, &mut world);
assert_eq!(names(&palette), ["GO ROUTE", "GO NPC", "GO FRONTIER", "MENU"]); assert_eq!(names(&palette), ["GO ROUTE", "GO NPC", "GO FRONTIER"]);
} }
#[test] #[test]
@ -802,7 +828,7 @@ fn an_overworld_with_no_way_out_leaves_the_leaving_buttons_unbound() {
let mut world = World::room(); let mut world = World::room();
let palette = Palette::for_scene(Scene::Overworld, &mut world); 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!(palette.slot(MacroId(MacroKind::GoOut.slot())), None, "a sealed room binds no way out");
assert_eq!(names(&palette), ["GO FRONTIER", "MENU"], "and there is always ground to cover"); assert_eq!(names(&palette), ["GO FRONTIER"], "and there is always ground to cover");
} }
#[test] #[test]
@ -1715,7 +1741,11 @@ fn go_frontier_is_unbound_once_every_reachable_tile_has_been_stood_on() {
fn menu_holds_start_until_the_start_menu_opens() { fn menu_holds_start_until_the_start_menu_opens() {
let mut world = World::room(); let mut world = World::room();
world.start_to_open = 2; world.start_to_open = 2;
assert_eq!(run(&mut world, MacroKind::Menu).unwrap(), MacroAbort::Done); // 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.pulses, vec![buttons::START, buttons::START]);
assert_eq!(world.scene, Scene::Menu); assert_eq!(world.scene, Scene::Menu);
} }
@ -1724,7 +1754,9 @@ fn menu_holds_start_until_the_start_menu_opens() {
fn menu_reports_blocked_when_the_start_menu_never_opens() { fn menu_reports_blocked_when_the_start_menu_never_opens() {
let mut world = World::room(); let mut world = World::room();
world.start_to_open = u8::MAX; world.start_to_open = u8::MAX;
assert_eq!(run(&mut world, MacroKind::Menu).unwrap(), MacroAbort::Blocked); 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] #[test]
@ -2268,7 +2300,7 @@ fn the_indoor_pad_is_section_nine_ones_set() {
let mut world = populated(); let mut world = populated();
assert_eq!( assert_eq!(
plan(&mut world), plan(&mut world),
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"], ["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"],
"no objective is known, so `GO OBJECTIVE` is not on the pad" "no objective is known, so `GO OBJECTIVE` is not on the pad"
); );
} }
@ -2277,7 +2309,7 @@ fn the_indoor_pad_is_section_nine_ones_set() {
fn the_outdoor_pad_has_the_route_and_no_passage_in_it() { fn the_outdoor_pad_has_the_route_and_no_passage_in_it() {
let mut world = populated(); let mut world = populated();
world.map = 0x00; world.map = 0x00;
assert_eq!(plan(&mut world), ["GO ROUTE", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"]); assert_eq!(plan(&mut world), ["GO ROUTE", "GO ITEM", "GO NPC", "GO FRONTIER"]);
} }
#[test] #[test]
@ -2304,7 +2336,7 @@ fn a_rung_on_another_floor_of_this_building_is_reached_through_the_passage() {
assert!(plan::passage_to_objective(&mut world)); assert!(plan::passage_to_objective(&mut world));
assert_eq!( assert_eq!(
plan(&mut world), plan(&mut world),
["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"] ["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. // 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(); let goals: Vec<Tile> = objective_goals(&mut world).into_iter().map(|aim| aim.tile).collect();
@ -2324,14 +2356,14 @@ fn an_objective_on_this_map_with_no_finer_place_falls_through() {
// *ledger* half of that fix (`ways`' tiers), and it is unchanged. // *ledger* half of that fix (`ways`' tiers), and it is unchanged.
assert_eq!( assert_eq!(
plan(&mut world), plan(&mut world),
["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"] ["GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"]
); );
// A rung the catalog does know a tile for is walked to instead. // 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 }); world.objective = Some(Objective { map: world.map, tile: Some(Tile::new(6, 3)), warp: None, edge: None, target: None });
assert_eq!( assert_eq!(
plan(&mut world), plan(&mut world),
["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER", "MENU"] ["GO OBJECTIVE", "GO OUT", "GO WARP", "GO ITEM", "GO NPC", "GO FRONTIER"]
); );
let aims = objective_goals(&mut world); let aims = objective_goals(&mut world);
assert_eq!(aims.iter().map(|aim| (aim.tile, aim.press)).collect::<Vec<_>>(), vec![ assert_eq!(aims.iter().map(|aim| (aim.tile, aim.press)).collect::<Vec<_>>(), vec![
@ -2351,14 +2383,14 @@ fn a_thing_this_session_has_talked_to_leaves_the_plan() {
world.talked.insert(TalkTarget::Sprite(1)); world.talked.insert(TalkTarget::Sprite(1));
assert_eq!( assert_eq!(
plan(&mut world), plan(&mut world),
["GO OUT", "GO WARP", "GO ITEM", "GO FRONTIER", "MENU"], ["GO OUT", "GO WARP", "GO ITEM", "GO FRONTIER"],
"the person drops out and the object stays" "the person drops out and the object stays"
); );
assert!(!precondition(MacroKind::GoNpc, &mut world), "and the button is gone"); assert!(!precondition(MacroKind::GoNpc, &mut world), "and the button is gone");
// The object is the other half, keyed by its own sprite slot. // The object is the other half, keyed by its own sprite slot.
world.talked.insert(TalkTarget::Sprite(2)); world.talked.insert(TalkTarget::Sprite(2));
assert_eq!(plan(&mut world), ["GO OUT", "GO WARP", "GO FRONTIER", "MENU"]); 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 // Palette mode reads the same ledger: an unbound slot is dim rather than aimed at a villager
// the fly has already heard out. // the fly has already heard out.
@ -2953,24 +2985,67 @@ fn a_no_route_refusal_records_what_it_could_not_reach() {
} }
#[test] #[test]
fn the_overworld_plan_never_truncates_the_frontier_or_the_menu_away() { fn the_overworld_plan_never_truncates_the_frontier_away() {
// Eleven buttons and six rows: the entries that used to be cut were the last ones, which are // Eleven buttons and six rows: the entry that used to be cut was the last one, which is the
// the fallback that always has somewhere to go while any ground is unexplored and -- since // fallback that always has somewhere to go while any ground is unexplored. `MENU` was the
// section 13.1 -- the unconditional `MENU` that makes an empty pad impossible. // other, and section 12.11 took it off the row rather than rescuing it again.
let mut world = populated(); let mut world = populated();
world.objective = Some(Objective { map: 0x00, tile: None, warp: None, edge: None, target: None }); 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 }]; world.signs = vec![Sign { x: 6, y: 6, text_id: 3 }];
let rows = plan(&mut world); let rows = plan(&mut world);
assert!(rows.len() >= 7, "a full overworld pad is more than six buttons: {rows:?}"); assert!(rows.len() >= 6, "a full overworld pad is more than five buttons: {rows:?}");
// Section 14: nothing is truncated at all any more, so the two that used to have to be
// rescued from the cut are simply there, in their own places at the end of the type order.
assert_eq!( assert_eq!(
&rows[rows.len() - 2..], rows.last(),
["GO FRONTIER", "MENU"], Some(&"GO FRONTIER"),
"the explorer and the start menu are both dealt: {rows:?}" "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] #[test]
fn no_playable_scene_deals_an_empty_pad() { 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 // The battle-text deadlock of v0.2.4 was one scene with nothing on the pad. This is the sweep
@ -3891,6 +3966,13 @@ fn every_macro_type_has_a_population_a_tag_and_a_gloss() {
/// ///
/// The sweep that catches an empty pad, which is the one state the doctrine cannot recover from on /// 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. /// 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] #[test]
fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() { fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
let worst = |world: &mut World| { let worst = |world: &mut World| {
@ -3902,12 +3984,17 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
); );
}; };
// The overworld, in all three of its rows, on a map with nothing on it and nowhere to go. // The overworld, in all three of its rows, on a map with nothing on it but its own door.
let mut bare = World::room(); 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(); 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); worst(&mut bare);
let mut outdoors = viridian(); let mut outdoors = viridian();
outdoors.warps.clear(); 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::Mart, maps::VIRIDIAN_CITY));
outdoors.areas.insert((Amenity::Center, 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.stood = (0..8).flat_map(|y| (0..8).map(move |x| Tile::new(x, y))).collect();
@ -3946,6 +4033,16 @@ fn no_playable_scene_and_no_sub_state_deals_an_empty_pad() {
cornered.list = list; cornered.list = list;
worst(&mut cornered); 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. /// Section 13.1's pad-empty rule: an outdoor map with every ledger against it still offers a walk.
@ -4132,6 +4229,95 @@ fn throw_ball_opens_the_bag_and_moves_the_cursor_to_the_ball_by_reading_it() {
assert_eq!(world.pulses.last(), Some(&buttons::A)); 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:?}"
);
}
}
/// Row 37 of `infra/docs/macros-traps.md`: a tile the cartridge pushes the fly off is not a tile /// 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. /// to stand on, and the ground beside a villager is not the villager's fault.
/// ///