From cf16c3ce9a4cffa46194829bec3f35cde8d6ccda Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Apr 2026 20:08:02 -0300 Subject: [PATCH] Implement Houndstone and Klefki effects Add Last Respects support for Houndstone by scaling attack damage from Psychic Pokemon in the attacker's discard pile. Add Dismantling Keys support for Klefki, including move generation limits, tool discard, knockout handling from lost HP tools, and focused Pokemon tests. --- src/actions/abilities/mechanic.rs | 1 + src/actions/apply_abilities_action.rs | 22 ++- src/actions/apply_attack_action.rs | 23 ++++ src/actions/attacks/mechanic.rs | 4 + src/actions/effect_ability_mechanic_map.rs | 5 +- src/actions/effect_mechanic_map.rs | 8 +- .../move_generation_abilities.rs | 13 ++ tests/pokemon.rs | 4 + .../pokemon/houndstone_last_respects_test.rs | 57 ++++++++ tests/pokemon/klefki_dismantling_keys_test.rs | 128 ++++++++++++++++++ 10 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 tests/pokemon/houndstone_last_respects_test.rs create mode 100644 tests/pokemon/klefki_dismantling_keys_test.rs diff --git a/src/actions/abilities/mechanic.rs b/src/actions/abilities/mechanic.rs index 011c9bd3..fc6fccd0 100644 --- a/src/actions/abilities/mechanic.rs +++ b/src/actions/abilities/mechanic.rs @@ -81,6 +81,7 @@ pub enum AbilityMechanic { }, SearchRandomPokemonFromDeck, MoveDamageFromOneYourPokemonToThisPokemon, + DiscardOpponentActiveToolsAndDiscardSelf, PreventFirstAttack, ElectromagneticWall, InfiltratingInspection, diff --git a/src/actions/apply_abilities_action.rs b/src/actions/apply_abilities_action.rs index 8807ad81..def1621b 100644 --- a/src/actions/apply_abilities_action.rs +++ b/src/actions/apply_abilities_action.rs @@ -6,7 +6,7 @@ use rand::{rngs::StdRng, Rng}; use crate::{ actions::{ abilities::AbilityMechanic, - apply_action_helpers::{handle_damage, Mutation}, + apply_action_helpers::{handle_damage, handle_knockouts, Mutation}, effect_ability_mechanic_map::ability_mechanic_from_effect, outcomes::Outcomes, shared_mutations::pokemon_search_outcomes, @@ -133,6 +133,7 @@ fn forecast_ability_by_mechanic( AbilityMechanic::MoveDamageFromOneYourPokemonToThisPokemon => { Outcomes::single(dusknoir_shadow_void(in_play_idx)) } + AbilityMechanic::DiscardOpponentActiveToolsAndDiscardSelf => dismantling_keys(in_play_idx), AbilityMechanic::PreventFirstAttack => { panic!("PreventFirstAttack is a passive ability") } @@ -609,6 +610,25 @@ fn dusknoir_shadow_void(dusknoir_idx: usize) -> Mutation { }) } +fn dismantling_keys(klefki_idx: usize) -> Outcomes { + Outcomes::single_fn(move |_rng, state, action| { + let opponent = (action.actor + 1) % 2; + if state + .maybe_get_active(opponent) + .is_none_or(|active| !active.has_tool_attached()) + { + return; + } + + state.discard_tool(opponent, 0); + handle_knockouts(state, (action.actor, klefki_idx), false); + + if state.in_play_pokemon[action.actor][klefki_idx].is_some() { + state.discard_from_play(action.actor, klefki_idx); + } + }) +} + fn umbreon_dark_chase(_: &mut StdRng, state: &mut State, action: &Action) { // Once during your turn, if this Pokémon is in the Active Spot, you may switch in 1 of your opponent's Benched Pokémon that has damage on it to the Active Spot. debug!("Umbreon ex's Dark Chase: Switching in opponent's damaged benched Pokemon"); diff --git a/src/actions/apply_attack_action.rs b/src/actions/apply_attack_action.rs index e789a710..ac3528fc 100644 --- a/src/actions/apply_attack_action.rs +++ b/src/actions/apply_attack_action.rs @@ -576,6 +576,15 @@ fn forecast_effect_attack_by_mechanic( attack.fixed_damage, *damage_per_supporter, ), + Mechanic::ExtraDamagePerPokemonTypeInDiscard { + energy_type, + damage_per_pokemon, + } => extra_damage_per_pokemon_type_in_discard_attack( + state, + attack.fixed_damage, + *energy_type, + *damage_per_pokemon, + ), Mechanic::ExtraDamagePerOwnPoint { damage_per_point } => { extra_damage_per_own_point_attack(state, attack.fixed_damage, *damage_per_point) } @@ -3012,6 +3021,20 @@ fn extra_damage_per_supporter_in_discard_attack( active_damage_doutcome(total_damage) } +fn extra_damage_per_pokemon_type_in_discard_attack( + state: &State, + base_damage: u32, + energy_type: EnergyType, + damage_per_pokemon: u32, +) -> Outcomes { + let pokemon_count = state.discard_piles[state.current_player] + .iter() + .filter(|card| matches!(card, Card::Pokemon(pokemon) if pokemon.energy_type == energy_type)) + .count() as u32; + let total_damage = base_damage + (pokemon_count * damage_per_pokemon); + active_damage_doutcome(total_damage) +} + /// Mega Manectric ex - Lightning Accelerator: Extra damage per point you have gotten fn extra_damage_per_own_point_attack( state: &State, diff --git a/src/actions/attacks/mechanic.rs b/src/actions/attacks/mechanic.rs index ba0f9b2b..c98117e9 100644 --- a/src/actions/attacks/mechanic.rs +++ b/src/actions/attacks/mechanic.rs @@ -187,6 +187,10 @@ pub enum Mechanic { ExtraDamagePerSupporterInDiscard { damage_per_supporter: u32, }, + ExtraDamagePerPokemonTypeInDiscard { + energy_type: EnergyType, + damage_per_pokemon: u32, + }, ExtraDamagePerOwnPoint { damage_per_point: u32, }, diff --git a/src/actions/effect_ability_mechanic_map.rs b/src/actions/effect_ability_mechanic_map.rs index afd405fd..32294630 100644 --- a/src/actions/effect_ability_mechanic_map.rs +++ b/src/actions/effect_ability_mechanic_map.rs @@ -169,7 +169,10 @@ pub static EFFECT_ABILITY_MECHANIC_MAP: LazyLock> = Lazy }, ); // map.insert("This attack also does 50 damage to 1 of your opponent's Benched Pokémon.", todo_implementation); - // map.insert("This attack does 20 more damage for each [P] Pokémon in your discard pile.", todo_implementation); + map.insert( + "This attack does 20 more damage for each [P] Pokémon in your discard pile.", + Mechanic::ExtraDamagePerPokemonTypeInDiscard { + energy_type: EnergyType::Psychic, + damage_per_pokemon: 20, + }, + ); // Promo-B // map.insert("If this Pokémon has any [P] Energy attached, this attack does 40 more damage. This attack's damage isn't affected by any effects on your opponent's Active Pokémon.", todo_implementation); diff --git a/src/move_generation/move_generation_abilities.rs b/src/move_generation/move_generation_abilities.rs index 4f2e01a9..56adce31 100644 --- a/src/move_generation/move_generation_abilities.rs +++ b/src/move_generation/move_generation_abilities.rs @@ -106,6 +106,9 @@ fn can_use_ability_by_mechanic( AbilityMechanic::MoveDamageFromOneYourPokemonToThisPokemon => { can_use_dusknoir_shadow_void(state, _in_play_index) } + AbilityMechanic::DiscardOpponentActiveToolsAndDiscardSelf => { + can_use_dismantling_keys(state, _in_play_index, card) + } AbilityMechanic::PreventFirstAttack => false, AbilityMechanic::ElectromagneticWall => false, AbilityMechanic::InfiltratingInspection => false, @@ -240,6 +243,16 @@ fn can_use_dusknoir_shadow_void(state: &State, dusknoir_idx: usize) -> bool { .any(|(i, p)| p.is_damaged() && i != dusknoir_idx) } +fn can_use_dismantling_keys(state: &State, in_play_idx: usize, card: &PlayedCard) -> bool { + if in_play_idx == 0 || card.ability_used { + return false; + } + let opponent = (state.current_player + 1) % 2; + state + .maybe_get_active(opponent) + .is_some_and(|active| active.has_tool_attached()) +} + fn can_use_crobat_cunning_link(state: &State, card: &PlayedCard) -> bool { if card.ability_used { return false; diff --git a/tests/pokemon.rs b/tests/pokemon.rs index 6ea79bc5..b02ceaba 100644 --- a/tests/pokemon.rs +++ b/tests/pokemon.rs @@ -32,8 +32,12 @@ mod emboar_flare_storm_test; mod gallade_test; #[path = "pokemon/grovyle_slicing_snipe_test.rs"] mod grovyle_slicing_snipe_test; +#[path = "pokemon/houndstone_last_respects_test.rs"] +mod houndstone_last_respects_test; #[path = "pokemon/jolteon_ex_test.rs"] mod jolteon_ex_test; +#[path = "pokemon/klefki_dismantling_keys_test.rs"] +mod klefki_dismantling_keys_test; #[path = "pokemon/legacy_ability_logic_test.rs"] mod legacy_ability_logic_test; #[path = "pokemon/lucario_b3_test.rs"] diff --git a/tests/pokemon/houndstone_last_respects_test.rs b/tests/pokemon/houndstone_last_respects_test.rs new file mode 100644 index 00000000..78003e65 --- /dev/null +++ b/tests/pokemon/houndstone_last_respects_test.rs @@ -0,0 +1,57 @@ +use deckgym::{ + actions::{Action, SimpleAction}, + card_ids::CardId, + database::get_card_by_enum, + models::{Card, EnergyType, PlayedCard}, + test_support::get_initialized_game, +}; + +fn played_card_with_base_hp(card_id: CardId, base_hp: u32) -> PlayedCard { + let card = get_card_by_enum(card_id); + PlayedCard::new(card, 0, base_hp, vec![], false, vec![]) +} + +fn use_last_respects(discard_piles: [Vec; 2]) -> u32 { + 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::B2a053Houndstone) + .with_energy(vec![EnergyType::Psychic, EnergyType::Colorless])], + vec![played_card_with_base_hp(CardId::A1001Bulbasaur, 200)], + ); + state.discard_piles = discard_piles; + game.set_state(state); + + game.apply_action(&Action { + actor: 0, + action: SimpleAction::Attack(0), + is_stack: false, + }); + + game.get_state_clone().get_active(1).get_remaining_hp() +} + +#[test] +fn test_houndstone_last_respects_does_base_damage_without_psychic_pokemon_in_discard() { + let remaining_hp = use_last_respects([vec![], vec![]]); + + assert_eq!(remaining_hp, 150); +} + +#[test] +fn test_houndstone_last_respects_counts_only_own_psychic_pokemon_in_discard() { + let remaining_hp = use_last_respects([ + vec![ + get_card_by_enum(CardId::A1128Mewtwo), + get_card_by_enum(CardId::A1130Ralts), + get_card_by_enum(CardId::A1120Gastly), + get_card_by_enum(CardId::A1001Bulbasaur), + get_card_by_enum(CardId::PA005PokeBall), + ], + vec![get_card_by_enum(CardId::A1131Kirlia)], + ]); + + assert_eq!(remaining_hp, 90); +} diff --git a/tests/pokemon/klefki_dismantling_keys_test.rs b/tests/pokemon/klefki_dismantling_keys_test.rs new file mode 100644 index 00000000..ab4767a8 --- /dev/null +++ b/tests/pokemon/klefki_dismantling_keys_test.rs @@ -0,0 +1,128 @@ +use deckgym::{ + actions::{Action, SimpleAction}, + card_ids::CardId, + database::get_card_by_enum, + models::PlayedCard, + test_support::get_initialized_game, +}; + +fn has_use_ability(actions: &[Action], in_play_idx: usize) -> bool { + actions + .iter() + .any(|action| matches!(action.action, SimpleAction::UseAbility { in_play_idx: idx } if idx == in_play_idx)) +} + +#[test] +fn test_klefki_dismantling_keys_discards_opponent_active_tool_and_self() { + let rocky_helmet = get_card_by_enum(CardId::A2148RockyHelmet); + let klefki = get_card_by_enum(CardId::B1120Klefki); + + 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), + PlayedCard::from_id(CardId::B1120Klefki), + ], + vec![PlayedCard::from_id(CardId::A1033Charmander).with_tool(rocky_helmet.clone())], + ); + game.set_state(state); + + let (actor, actions) = game.get_state_clone().generate_possible_actions(); + assert_eq!(actor, 0); + assert!(has_use_ability(&actions, 1)); + + let ability_action = actions + .iter() + .find(|action| matches!(action.action, SimpleAction::UseAbility { in_play_idx: 1 })) + .expect("Dismantling Keys should be available from the Bench") + .clone(); + game.apply_action(&ability_action); + + let state = game.get_state_clone(); + assert!(state.get_active(1).attached_tool.is_none()); + assert!(state.discard_piles[1].contains(&rocky_helmet)); + assert!(state.in_play_pokemon[0][1].is_none()); + assert!(state.discard_piles[0].contains(&klefki)); + assert_eq!(state.points, [0, 0]); +} + +#[test] +fn test_klefki_dismantling_keys_requires_bench_and_opponent_active_tool() { + let rocky_helmet = get_card_by_enum(CardId::A2148RockyHelmet); + + let mut active_klefki_game = get_initialized_game(0); + let mut active_klefki_state = active_klefki_game.get_state_clone(); + active_klefki_state.current_player = 0; + active_klefki_state.set_board( + vec![PlayedCard::from_id(CardId::B1120Klefki)], + vec![PlayedCard::from_id(CardId::A1033Charmander).with_tool(rocky_helmet)], + ); + active_klefki_game.set_state(active_klefki_state); + + let (_actor, actions) = active_klefki_game + .get_state_clone() + .generate_possible_actions(); + assert!(!has_use_ability(&actions, 0)); + + let mut no_tool_game = get_initialized_game(0); + let mut no_tool_state = no_tool_game.get_state_clone(); + no_tool_state.current_player = 0; + no_tool_state.set_board( + vec![ + PlayedCard::from_id(CardId::A1001Bulbasaur), + PlayedCard::from_id(CardId::B1120Klefki), + ], + vec![PlayedCard::from_id(CardId::A1033Charmander)], + ); + no_tool_game.set_state(no_tool_state); + + let (_actor, actions) = no_tool_game.get_state_clone().generate_possible_actions(); + assert!(!has_use_ability(&actions, 1)); +} + +#[test] +fn test_klefki_dismantling_keys_resolves_ko_from_lost_giant_cape() { + let giant_cape = get_card_by_enum(CardId::A2147GiantCape); + + let mut game = get_initialized_game(0); + let mut state = game.get_state_clone(); + state.current_player = 0; + state.turn_count = 3; + state.points = [0, 0]; + state.set_board( + vec![ + PlayedCard::from_id(CardId::A1001Bulbasaur), + PlayedCard::from_id(CardId::B1120Klefki), + ], + vec![ + PlayedCard::from_id(CardId::A1001Bulbasaur) + .with_tool(giant_cape.clone()) + .with_damage(80), + PlayedCard::from_id(CardId::A1053Squirtle), + ], + ); + game.set_state(state); + + let ability_action = game + .get_state_clone() + .generate_possible_actions() + .1 + .into_iter() + .find(|action| matches!(action.action, SimpleAction::UseAbility { in_play_idx: 1 })) + .expect("Dismantling Keys should be available from the Bench"); + game.apply_action(&ability_action); + + let state = game.get_state_clone(); + assert!(state.in_play_pokemon[1][0].is_none()); + assert!(state.discard_piles[1].contains(&giant_cape)); + assert!(state.in_play_pokemon[0][1].is_none()); + assert_eq!(state.points[0], 1); + + let (actor, actions) = state.generate_possible_actions(); + assert_eq!(actor, 1); + assert!(actions + .iter() + .any(|action| matches!(action.action, SimpleAction::Activate { player: 1, .. }))); +}