Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/actions/apply_action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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"),
}
Expand Down
25 changes: 25 additions & 0 deletions src/actions/apply_trainer_action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
}
Expand Down Expand Up @@ -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<SimpleAction> = (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::<Vec<_>>()
})
.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<usize> = state
Expand Down
11 changes: 11 additions & 0 deletions src/actions/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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"),
}
Expand Down
14 changes: 14 additions & 0 deletions src/move_generation/move_generation_trainer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -767,3 +768,16 @@ fn can_play_maintenance(state: &State, trainer_card: &TrainerCard) -> Option<Vec
cannot_play_trainer()
}
}

/// Check if Field Blower can be played (requires any Pokémon with a tool attached, or an active stadium)
fn can_play_field_blower(state: &State, trainer_card: &TrainerCard) -> Option<Vec<SimpleAction>> {
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()
}
}
2 changes: 2 additions & 0 deletions src/players/weighted_random_player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions tests/trainers.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
#[path = "trainers/field_blower_test.rs"]
mod field_blower_test;
#[path = "trainers/iris_trainer_test.rs"]
mod iris_trainer_test;
137 changes: 137 additions & 0 deletions tests/trainers/field_blower_test.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
Loading