diff --git a/src/actions/apply_action.rs b/src/actions/apply_action.rs index 05c0bbc9..dc8adb18 100644 --- a/src/actions/apply_action.rs +++ b/src/actions/apply_action.rs @@ -63,6 +63,8 @@ pub fn forecast_action(state: &State, action: &Action) -> Outcomes { | SimpleAction::HealAllEeveeEvolutions | SimpleAction::DiscardFossil { .. } | SimpleAction::ReturnPokemonToHand { .. } + | SimpleAction::DiscardToolFromPokemon { .. } + | SimpleAction::DiscardActiveStadium | SimpleAction::Noop => forecast_deterministic_action(), SimpleAction::UseAbility { in_play_idx } => forecast_ability(state, action, *in_play_idx), SimpleAction::Attack(index) => forecast_attack(action.actor, state, *index), @@ -240,6 +242,17 @@ fn apply_deterministic_action(state: &mut State, action: &Action) { SimpleAction::ReturnPokemonToHand { in_play_idx } => { apply_return_pokemon_to_hand(action.actor, state, *in_play_idx) } + SimpleAction::DiscardToolFromPokemon { + player, + in_play_idx, + } => { + state.discard_tool(*player, *in_play_idx); + } + SimpleAction::DiscardActiveStadium => { + if let Some(stadium) = state.active_stadium.take() { + state.discard_piles[action.actor].push(stadium); + } + } SimpleAction::Noop => {} _ => panic!("Deterministic Action expected"), } diff --git a/src/actions/apply_trainer_action.rs b/src/actions/apply_trainer_action.rs index 206e9798..29e8da9d 100644 --- a/src/actions/apply_trainer_action.rs +++ b/src/actions/apply_trainer_action.rs @@ -179,6 +179,7 @@ pub fn forecast_trainer_action( CardId::A2b072TeamRocketGrunt | CardId::A2b091TeamRocketGrunt => { team_rocket_grunt_outcomes() } + CardId::B3147FieldBlower => Outcomes::single_fn(field_blower_effect), _ => panic!("Unsupported Trainer Card"), } } @@ -308,6 +309,30 @@ fn lillie_effect(_: &mut StdRng, state: &mut State, action: &Action) { } } +fn field_blower_effect(_: &mut StdRng, state: &mut State, action: &Action) { + // Offer one choice per Pokémon with a tool (both players) plus one choice to discard the stadium. + let mut choices: Vec = (0..2) + .flat_map(|player| { + state + .enumerate_in_play_pokemon(player) + .filter(|(_, pokemon)| pokemon.has_tool_attached()) + .map( + move |(in_play_idx, _)| SimpleAction::DiscardToolFromPokemon { + player, + in_play_idx, + }, + ) + .collect::>() + }) + .collect(); + if state.active_stadium.is_some() { + choices.push(SimpleAction::DiscardActiveStadium); + } + if !choices.is_empty() { + state.move_generation_stack.push((action.actor, choices)); + } +} + fn guzma_effect(_: &mut StdRng, state: &mut State, action: &Action) { let opponent = (action.actor + 1) % 2; let tool_indices: Vec = state diff --git a/src/actions/types.rs b/src/actions/types.rs index 04cb838b..cd893837 100644 --- a/src/actions/types.rs +++ b/src/actions/types.rs @@ -135,6 +135,13 @@ pub enum SimpleAction { ReturnPokemonToHand { in_play_idx: usize, }, + /// Field Blower: discard the tool attached to a specific Pokémon (any player). + DiscardToolFromPokemon { + player: usize, + in_play_idx: usize, + }, + /// Field Blower: discard the active stadium. + DiscardActiveStadium, Noop, // No operation, used to have the user say "no" to a question } @@ -279,6 +286,10 @@ impl fmt::Display for SimpleAction { SimpleAction::ReturnPokemonToHand { in_play_idx } => { write!(f, "ReturnPokemonToHand({in_play_idx})") } + SimpleAction::DiscardToolFromPokemon { player, in_play_idx } => { + write!(f, "DiscardToolFromPokemon({player}, {in_play_idx})") + } + SimpleAction::DiscardActiveStadium => write!(f, "DiscardActiveStadium"), SimpleAction::UseStadium => write!(f, "UseStadium"), SimpleAction::Noop => write!(f, "Noop"), } diff --git a/src/move_generation/move_generation_trainer.rs b/src/move_generation/move_generation_trainer.rs index b2d2f4b9..727f9968 100644 --- a/src/move_generation/move_generation_trainer.rs +++ b/src/move_generation/move_generation_trainer.rs @@ -218,6 +218,7 @@ pub fn trainer_move_generation_implementation( CardId::A2b072TeamRocketGrunt | CardId::A2b091TeamRocketGrunt => { can_play_team_rocket_grunt(state, trainer_card) } + CardId::B3147FieldBlower => can_play_field_blower(state, trainer_card), _ => None, } } @@ -767,3 +768,16 @@ fn can_play_maintenance(state: &State, trainer_card: &TrainerCard) -> Option Option> { + let any_tool = (0..2) + .flat_map(|player| state.enumerate_in_play_pokemon(player)) + .any(|(_, pokemon)| pokemon.has_tool_attached()); + let any_stadium = state.active_stadium.is_some(); + if any_tool || any_stadium { + can_play_trainer(state, trainer_card) + } else { + cannot_play_trainer() + } +} diff --git a/src/players/weighted_random_player.rs b/src/players/weighted_random_player.rs index 2fe0630a..477d804d 100644 --- a/src/players/weighted_random_player.rs +++ b/src/players/weighted_random_player.rs @@ -68,6 +68,8 @@ fn get_weight(action: &SimpleAction) -> u32 { SimpleAction::HealAllEeveeEvolutions => 5, SimpleAction::DiscardFossil { .. } => 1, // Low weight to discard fossils SimpleAction::ReturnPokemonToHand { .. } => 5, + SimpleAction::DiscardToolFromPokemon { .. } => 5, + SimpleAction::DiscardActiveStadium => 5, SimpleAction::UseStadium => 5, // Stadium abilities like Mesagoza SimpleAction::Noop => 0, // No operation has no weight } diff --git a/tests/trainers.rs b/tests/trainers.rs index 526ee7a7..169e2c08 100644 --- a/tests/trainers.rs +++ b/tests/trainers.rs @@ -1,2 +1,4 @@ +#[path = "trainers/field_blower_test.rs"] +mod field_blower_test; #[path = "trainers/iris_trainer_test.rs"] mod iris_trainer_test; diff --git a/tests/trainers/field_blower_test.rs b/tests/trainers/field_blower_test.rs new file mode 100644 index 00000000..27308958 --- /dev/null +++ b/tests/trainers/field_blower_test.rs @@ -0,0 +1,137 @@ +use deckgym::{ + actions::{Action, SimpleAction}, + card_ids::CardId, + database::get_card_by_enum, + models::{Card, PlayedCard, TrainerCard}, + test_support::get_initialized_game, +}; + +fn make_field_blower_trainer_card() -> TrainerCard { + match get_card_by_enum(CardId::B3147FieldBlower) { + Card::Trainer(tc) => tc, + _ => panic!("Expected trainer card"), + } +} + +fn make_rocky_helmet_card() -> Card { + get_card_by_enum(CardId::A2148RockyHelmet) +} + +#[test] +fn test_field_blower_discards_opponents_tool() { + let mut game = get_initialized_game(0); + let mut state = game.get_state_clone(); + state.current_player = 0; + + state.set_board( + vec![PlayedCard::from_id(CardId::A1001Bulbasaur)], + vec![PlayedCard::from_id(CardId::A1033Charmander).with_tool(make_rocky_helmet_card())], + ); + state.hands[0].clear(); + let trainer_card = make_field_blower_trainer_card(); + state.hands[0].push(Card::Trainer(trainer_card.clone())); + game.set_state(state); + + let play_action = Action { + actor: 0, + action: SimpleAction::Play { trainer_card }, + is_stack: false, + }; + game.apply_action(&play_action); + + // Player must choose what to discard + let state = game.get_state_clone(); + let (actor, choices) = state.generate_possible_actions(); + assert_eq!(actor, 0); + assert!( + choices.iter().any(|a| matches!( + a.action, + SimpleAction::DiscardToolFromPokemon { + player: 1, + in_play_idx: 0 + } + )), + "Should offer discarding opponent's tool" + ); + + // Apply the discard + let discard_action = choices + .iter() + .find(|a| { + matches!( + a.action, + SimpleAction::DiscardToolFromPokemon { + player: 1, + in_play_idx: 0 + } + ) + }) + .unwrap() + .clone(); + game.apply_action(&discard_action); + + let state = game.get_state_clone(); + assert!( + state.in_play_pokemon[1][0] + .as_ref() + .unwrap() + .attached_tool + .is_none(), + "Opponent's tool should be discarded" + ); + assert!( + state.discard_piles[1].contains(&make_rocky_helmet_card()), + "Tool should be in opponent's discard pile" + ); +} + +#[test] +fn test_field_blower_discards_active_stadium() { + let mut game = get_initialized_game(0); + let mut state = game.get_state_clone(); + state.current_player = 0; + + state.set_board( + vec![PlayedCard::from_id(CardId::A1001Bulbasaur)], + vec![PlayedCard::from_id(CardId::A1033Charmander)], + ); + // Place a stadium + let stadium_card = get_card_by_enum(CardId::B2155PeculiarPlaza); + state.active_stadium = Some(stadium_card.clone()); + + state.hands[0].clear(); + let trainer_card = make_field_blower_trainer_card(); + state.hands[0].push(Card::Trainer(trainer_card.clone())); + game.set_state(state); + + let play_action = Action { + actor: 0, + action: SimpleAction::Play { trainer_card }, + is_stack: false, + }; + game.apply_action(&play_action); + + let state = game.get_state_clone(); + let (actor, choices) = state.generate_possible_actions(); + assert_eq!(actor, 0); + assert!( + choices + .iter() + .any(|a| matches!(a.action, SimpleAction::DiscardActiveStadium)), + "Should offer discarding the active stadium" + ); + + let discard_action = choices + .iter() + .find(|a| matches!(a.action, SimpleAction::DiscardActiveStadium)) + .unwrap() + .clone(); + game.apply_action(&discard_action); + + let state = game.get_state_clone(); + assert!(state.active_stadium.is_none(), "Stadium should be gone"); + assert!( + state.discard_piles[0].contains(&stadium_card), + "Stadium should be in player's discard pile" + ); +}