diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs index c0bdae3..588fc86 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs @@ -1893,7 +1893,8 @@ pub const fn move_index(kind: MacroKind) -> Option { } } -/// Whether `kind`'s move slot holds a move with PP: the four buttons' precondition (section 14). +/// Whether `kind`'s move slot holds a move with PP that the battle engine will not answer with +/// nothing: the four buttons' precondition (section 14, row 60). /// /// Three things it is *not*, each of them a bug this palette has had: /// @@ -1921,23 +1922,41 @@ pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool { // all, so the button is bound there whatever the seam can make of the battler. That is the // backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has // a button that ends the turn, and it is never one that merely reopens a list. - if index == 0 && matches!(battle.menu, BattleMenu::Main { .. }) { - return true; - } + let main = matches!(battle.menu, BattleMenu::Main { .. }); // And the same backstop over an **open move list** whose battler the seam cannot read // (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the // list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in // `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so // it reads no move either, and confirming a move is what ends a turn. let Some(own) = battle.own else { - return index == 0 && matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. }); + return index == 0 + && (main || matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. })); }; let holds = |slot: usize| -> Option<&Move> { own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0) }; - let Some(entry) = holds(usize::from(index)) else { return false }; + // **A move the cartridge will answer with nothing is not dealt beside one it will not** + // (row 60, section 12.23). Live on Route 1: Squirtle's TAIL WHIP against a Pidgey whose + // DEFENSE was already at -6 was `MOVE 2` 183 times, "Nothing happened!" every time, and no + // battle ended by the fly's hand. What the move does is the move table's and the effect + // routine's answer ([`MacroState::move_without_effect`]), read the same way for every move, + // and it is PP's rule over again: a spent move is not offered beside a usable one, and when + // nothing is usable what was dealt stays dealt -- taking the last moves away would leave a list + // whose only button is `BACK`, which is 12.11's pair. + let mut useful = [false; 4]; + for (slot, flag) in useful.iter_mut().enumerate() { + if let Some(entry) = holds(slot).copied() { + *flag = entry.pp > 0 && !state.move_without_effect(entry.id); + } + } + let any_useful = useful.iter().any(|flag| *flag); + let entry = holds(usize::from(index)).copied(); + if index == 0 && main { + return !any_useful || useful[0] || entry.is_none_or(|entry| entry.pp == 0); + } + let Some(entry) = entry else { return false }; if entry.pp > 0 { - return true; + return useful[usize::from(index)] || !any_useful; } // Out of PP. Only `MOVE 1` stays, and only when nothing else has any either -- otherwise the // fly would be offered a spent move beside a usable one. diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs index 097ba80..fc7d051 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs @@ -111,6 +111,8 @@ struct World { money: u32, bag: Vec<(u8, u8)>, stock: Vec, + /// Move ids the battle engine would answer with "Nothing happened!" on this frame (row 60). + no_effect: BTreeSet, /// Tiles the game lets the player talk *over*: a mart's or a centre's counter. counters: BTreeSet, /// Errands this run has discharged (`docs/design/macros.md` section 13). @@ -242,6 +244,7 @@ impl World { pushes: BTreeSet::new(), exhausted: BTreeSet::new(), stock: Vec::new(), + no_effect: BTreeSet::new(), visited: BTreeSet::new(), stood: BTreeSet::new(), seen_maps: BTreeSet::new(), @@ -696,6 +699,10 @@ impl MacroState for World { self.prompt && self.scene == Scene::Dialog } + fn move_without_effect(&mut self, id: u8) -> bool { + self.no_effect.contains(&id) + } + fn shop_stock(&mut self) -> Vec { self.stock.clone() } @@ -3939,6 +3946,95 @@ fn a_turn_with_nothing_to_attack_switch_or_flee_with_still_has_a_button() { assert!(pad_of(&mut world).contains(&"MOVE 1")); } +/// `constants/move_constants.asm`: the two moves a level-5 Squirtle knows. +const TACKLE: u8 = 0x21; +const TAIL_WHIP: u8 = 0x27; + +#[test] +fn a_move_the_cartridge_answers_with_nothing_is_off_the_pad_beside_one_it_does_not() { + // Row 60, live on Route 1: Squirtle L5 with TACKLE and TAIL WHIP, a Pidgey whose DEFENSE is + // already at -6, and `MOVE 2` chosen 183 times to "Nothing happened!". Over the menu and over + // the open list, TAIL WHIP leaves the pad and TACKLE stays. + let mut world = World::battle(); + world.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])]; + world.active = Some(0); + world.list = List::BattleMain; + assert!(on_the_pad(&mut world, MacroKind::Move2), "before the stage is at its limit"); + + world.no_effect.insert(TAIL_WHIP); + for list in [List::BattleMain, List::Moves(2)] { + world.list = list; + world.grid = list == List::BattleMain; + let pad = pad_of(&mut world); + assert!(!pad.contains(&"MOVE 2"), "{list:?} deals {pad:?}"); + assert!(pad.contains(&"MOVE 1"), "{list:?} deals {pad:?}"); + } + // Nothing presses for the fly: the button is gone, and nothing is chosen in its place. + world.list = List::BattleMain; + world.grid = true; + assert!(!move_slot_bound(&mut world, MacroKind::Move2)); + + // Slot one is read the same way: FIGHT's backstop over the menu is not a way to deal a move + // that does nothing beside one that does. + let mut swapped = World::battle(); + swapped.mons = vec![mon(0, 8, 20, &[(TAIL_WHIP, 30), (TACKLE, 35)])]; + swapped.active = Some(0); + swapped.list = List::BattleMain; + swapped.no_effect.insert(TAIL_WHIP); + assert_eq!( + pad_of(&mut swapped).iter().filter(|name| name.starts_with("MOVE")).collect::>(), + [&"MOVE 2"] + ); +} + +#[test] +fn with_no_move_that_does_anything_the_moves_stay_as_pp_deals_them() { + // PP's own rule (section 12.8, row 30a): with nothing usable, what ends the turn stays on the + // pad. Taking every move away over an open list would leave `BACK` alone, which closes what + // `MOVE 1` on the menu underneath opened -- 12.11's pair. + let mut world = World::battle(); + world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30)])]; + world.active = Some(0); + world.no_effect.insert(TAIL_WHIP); + world.list = List::Moves(2); + world.grid = false; + world.cursor_max = 1; + let pad = pad_of(&mut world); + assert!(pad.contains(&"MOVE 2"), "the one move with PP still ends the turn: {pad:?}"); + assert_ne!(pad, ["BACK"]); + + world.list = List::BattleMain; + world.grid = true; + let pad = pad_of(&mut world); + assert!(pad.contains(&"MOVE 1") && pad.contains(&"MOVE 2"), "{pad:?}"); + + // And both moves without effect: nothing changes from what PP alone deals. + let mut both = World::battle(); + both.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])]; + both.active = Some(0); + both.list = List::BattleMain; + let before = pad_of(&mut both); + both.no_effect.extend([TACKLE, TAIL_WHIP]); + assert_eq!(pad_of(&mut both), before); +} + +#[test] +fn a_spent_move_and_a_move_without_effect_leave_the_one_that_works() { + // The two readings together: slot one spent, slot two refused, slot three usable. Only + // `MOVE 3` is a move; `MOVE 1` over the menu is FIGHT's backstop only while slot one is the + // thing that can end the turn, and it is spent -- row 34's behaviour, unchanged. + let mut world = World::battle(); + world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30), (0x2d, 40)])]; + world.active = Some(0); + world.no_effect.insert(TAIL_WHIP); + world.list = List::Moves(3); + world.grid = false; + world.cursor_max = 2; + let moves: Vec<&str> = + pad_of(&mut world).into_iter().filter(|name| name.starts_with("MOVE")).collect(); + assert_eq!(moves, ["MOVE 3"]); +} + /// 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();