diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 571d701e5..6b9bfa578 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -160,6 +160,10 @@ fn is_data_carrying_static(mode: &StaticMode) -> bool { // with turns.rs::execute_untap_with_choices keeping a cap clamp as a // safety net. Parameterized — no registry entry; coverage support here. | StaticMode::MaxUntapPerType { .. } + // CR 509.1a + CR 509.1b: ExtraBlockers carries the additional-blocker + // count (Yare, Brave the Sands). Runtime enforcement is in + // combat.rs::extra_block_limit; the registry only keys Some(1)/None. + | StaticMode::ExtraBlockers { .. } // CR 702.122a / 702.171a / 702.184a: CrewContribution carries the // modifier kind + action list (Giant Ox, Hotshot Mechanic). Runtime // enforcement is in static_abilities.rs::object_crew_power_contribution. diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index 02f7d6658..e154594da 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -706,11 +706,12 @@ fn try_parse_can_block_additional( parse_subject_application(subject_text.trim(), ctx)? }; - let (_rest, (_, _, _, _, count, duration, _)) = all_consuming(( + let (_rest, (_, _, _, _, _, count, duration, _)) = all_consuming(( tag("can"), tag(" "), tag("block"), tag(" "), + opt(tag("up to ")), parse_extra_blockers_count, parse_block_grant_duration, opt(tag(".")), @@ -748,6 +749,7 @@ pub(super) fn is_can_block_extra_predicate(lower: &str) -> bool { tag(" "), tag("block"), tag(" "), + opt(tag("up to ")), parse_extra_blockers_count, parse_block_grant_duration, opt(tag(".")), @@ -5217,6 +5219,38 @@ mod tests { } } + /// Yare — "That creature can block up to two additional creatures this turn." + /// The optional "up to" prefix must not swallow the extra-block grant. + #[test] + fn yare_spell_extra_blockers_up_to_two_this_turn() { + let def = crate::parser::oracle_effect::parse_effect_chain( + "Target creature defending player controls gets +3/+0 until end of turn. That creature can block up to two additional creatures this turn.", + AbilityKind::Spell, + ); + let sub = def + .sub_ability + .as_ref() + .expect("Yare extra-block clause must be a sub-ability"); + assert!( + !matches!(*sub.effect, Effect::Unimplemented { .. }), + "Yare extra-block clause must parse, got {:?}", + sub.effect + ); + let Effect::GenericEffect { + static_abilities, + duration, + .. + } = &*sub.effect + else { + panic!("expected GenericEffect, got {:?}", sub.effect); + }; + assert_eq!(duration, &Some(Duration::UntilEndOfTurn)); + assert_eq!( + static_abilities[0].mode, + StaticMode::ExtraBlockers { count: Some(2) } + ); + } + /// CR 509.1b + CR 611.2: A granted "can't be blocked [this turn] except by /// " clause (Fast // Furious's second sentence) must lower to a real /// `CantBeBlockedExceptBy` evasion static on the anaphoric "It" subject — diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 72fba8e6f..4a1298e9b 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -415,4 +415,5 @@ mod who_villainous_choice_scoped_player; mod wise_mothman_milled_trigger; mod wise_mothman_target_distinctness; mod wrenn_and_six_up_to_one_optout; +mod yare_extra_blockers; mod yuriko_combat_damage; diff --git a/crates/engine/tests/integration/yare_extra_blockers.rs b/crates/engine/tests/integration/yare_extra_blockers.rs new file mode 100644 index 000000000..8245865aa --- /dev/null +++ b/crates/engine/tests/integration/yare_extra_blockers.rs @@ -0,0 +1,110 @@ +//! Yare — "That creature can block up to two additional creatures this turn." +//! +//! Parser regression: the optional "up to" prefix must lower to +//! `ExtraBlockers { count: Some(2) }`, not `Effect::Unimplemented`. +//! +//! Runtime regression: a blocker granted that static can legally block three +//! attackers (default 1 + 2 additional). + +use engine::game::combat::{validate_blockers, AttackerInfo, CombatState}; +use engine::game::zones::create_object; +use engine::parser::oracle_effect::parse_effect_chain; +use engine::types::ability::{AbilityKind, Effect, StaticDefinition}; +use engine::types::card_type::CoreType; +use engine::types::format::FormatConfig; +use engine::types::game_state::GameState; +use engine::types::identifiers::CardId; +use engine::types::player::PlayerId; +use engine::types::statics::StaticMode; +use engine::types::zones::Zone; + +const YARE_ORACLE: &str = "Target creature defending player controls gets +3/+0 until end of turn. That creature can block up to two additional creatures this turn."; + +fn create_creature( + state: &mut GameState, + controller: PlayerId, + name: &str, + power: i32, + toughness: i32, +) -> engine::types::identifiers::ObjectId { + let id = create_object( + state, + CardId(state.next_object_id), + controller, + name.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types = vec![CoreType::Creature]; + obj.base_card_types = obj.card_types.clone(); + obj.power = Some(power); + obj.toughness = Some(toughness); + obj.base_power = Some(power); + obj.base_toughness = Some(toughness); + obj.summoning_sick = false; + id +} + +#[test] +fn yare_parses_up_to_two_additional_blockers_sub_ability() { + let def = parse_effect_chain(YARE_ORACLE, AbilityKind::Spell); + let sub = def + .sub_ability + .expect("Yare must parse a supported extra-block sub-ability"); + let Effect::GenericEffect { + static_abilities, + duration, + .. + } = &*sub.effect + else { + panic!("expected GenericEffect, got {:?}", sub.effect); + }; + assert_eq!( + duration, + &Some(engine::types::ability::Duration::UntilEndOfTurn) + ); + assert_eq!( + static_abilities[0].mode, + StaticMode::ExtraBlockers { count: Some(2) } + ); +} + +#[test] +fn yare_extra_blockers_allow_three_attackers_blocked() { + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let attacker1 = create_creature(&mut state, PlayerId(0), "Attacker A", 2, 2); + let attacker2 = create_creature(&mut state, PlayerId(0), "Attacker B", 2, 2); + let attacker3 = create_creature(&mut state, PlayerId(0), "Attacker C", 2, 2); + let blocker = create_creature(&mut state, PlayerId(1), "Defender", 2, 2); + + state + .objects + .get_mut(&blocker) + .unwrap() + .static_definitions + .push(StaticDefinition::new(StaticMode::ExtraBlockers { + count: Some(2), + })); + + state.combat = Some(CombatState { + attackers: vec![ + AttackerInfo::attacking_player(attacker1, PlayerId(1)), + AttackerInfo::attacking_player(attacker2, PlayerId(1)), + AttackerInfo::attacking_player(attacker3, PlayerId(1)), + ], + ..Default::default() + }); + + assert!( + validate_blockers( + &state, + &[ + (blocker, attacker1), + (blocker, attacker2), + (blocker, attacker3), + ], + ) + .is_ok(), + "ExtraBlockers(2) must allow blocking three attackers" + ); +}