diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index 8b0157d18b..e26a9d88e7 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -14,7 +14,7 @@ use crate::types::keywords::Keyword; use crate::types::mana::ManaColor; use crate::types::player::PlayerId; use crate::types::statics::{ - BlockExceptionKind, CombatAloneAction, CombatAloneRequirement, StaticMode, + AttackDefenderScope, BlockExceptionKind, CombatAloneAction, CombatAloneRequirement, StaticMode, }; use crate::types::zones::Zone; @@ -511,15 +511,75 @@ pub fn validate_attackers(state: &GameState, attacker_ids: &[ObjectId]) -> Resul Ok(()) } +/// CR 508.1c: The global "no more than N creatures can attack each combat" cap +/// (`defender: None`). Defender-scoped caps ("...attack you each combat") are +/// enforced separately by `validate_per_defender_attacker_caps` because they +/// restrict only attacks against a specific player. fn max_attackers_each_combat(state: &GameState) -> Option { super::functioning_abilities::battlefield_active_statics(state) .filter_map(|(_, def)| match def.mode { - StaticMode::MaxAttackersEachCombat { max } => Some(max), + StaticMode::MaxAttackersEachCombat { + max, + defender: None, + } => Some(max), _ => None, }) .min() } +/// CR 508.1c + CR 508.5 + CR 802.1: Enforce defender-scoped attacker caps +/// (`MaxAttackersEachCombat { defender: Some(_) }`, e.g. Judoon Enforcers' +/// "no more than one creature can attack you each combat"). Each such static +/// limits only the attackers whose defending player (CR 508.5) is the static's +/// controller, so opponents may still be attacked freely. Returns an error if +/// any active defender-scoped cap is exceeded. +fn validate_per_defender_attacker_caps( + state: &GameState, + attacks: &[(ObjectId, AttackTarget)], +) -> Result<(), String> { + for (source, def) in super::functioning_abilities::battlefield_active_statics(state) { + let StaticMode::MaxAttackersEachCombat { + max, + defender: Some(AttackDefenderScope::Controller), + } = def.mode + else { + continue; + }; + // CR 109.5: "you" resolves to the controller of the permanent carrying + // the static. + let protected_player = source.controller; + let count = attacks + .iter() + .filter(|(_, target)| defending_player_for_target(state, *target) == protected_player) + .count() as u32; + if count > max { + return Err(format!( + "No more than {max} creature(s) can attack {protected_player:?} each combat (CR 508.1c)" + )); + } + } + Ok(()) +} + +/// CR 508.5 + CR 310.8d: Resolve the defending player for an `AttackTarget` — +/// the player for a direct attack, a planeswalker's controller, or a battle's +/// protector. +fn defending_player_for_target(state: &GameState, target: AttackTarget) -> PlayerId { + match target { + AttackTarget::Player(pid) => pid, + AttackTarget::Planeswalker(pw_id) => state + .objects + .get(&pw_id) + .map(|pw| pw.controller) + .unwrap_or(PlayerId(0)), + AttackTarget::Battle(battle_id) => state + .objects + .get(&battle_id) + .and_then(|b| b.protector()) + .unwrap_or(PlayerId(0)), + } +} + /// Iterate every battlefield `StaticDefinition` whose mode is a block-restriction /// (`CantBeBlocked`, `CantBeBlockedExceptBy`, or `CantBeBlockedBy`) AND whose /// `affected` filter matches `attacker_id`. Yields `(source, def)` pairs so @@ -2004,6 +2064,10 @@ pub fn declare_attackers_with_bands( ) -> Result<(), String> { let attacker_ids: Vec = attacks.iter().map(|(id, _)| *id).collect(); validate_attackers(state, &attacker_ids)?; + // CR 508.1c + CR 508.5: defender-scoped attacker caps ("...attack you each + // combat") need per-target defending players, which only the full `attacks` + // slice carries — enforce them here rather than in `validate_attackers`. + validate_per_defender_attacker_caps(state, attacks)?; if !bands.is_empty() { validate_attack_band_declarations(state, attacks, bands)?; } @@ -2209,19 +2273,7 @@ pub fn declare_attackers_with_bands( .map(|(object_id, target)| { // CR 508.5 + CR 310.8d: Defending player for a battle = its protector, // not its controller. For planeswalkers, defending player = controller. - let defending_player = match target { - AttackTarget::Player(pid) => *pid, - AttackTarget::Planeswalker(pw_id) => state - .objects - .get(pw_id) - .map(|pw| pw.controller) - .unwrap_or(PlayerId(0)), - AttackTarget::Battle(battle_id) => state - .objects - .get(battle_id) - .and_then(|b| b.protector()) - .unwrap_or(PlayerId(0)), - }; + let defending_player = defending_player_for_target(state, *target); AttackerInfo::new(*object_id, *target, defending_player) }) .collect(); @@ -3345,6 +3397,45 @@ mod tests { assert!(validate_attackers(&state, &[unflagged, companion]).is_ok()); } + #[test] + fn defender_scoped_attacker_cap_limits_attacks_against_controller() { + // CR 508.1c + CR 508.5 + CR 802.1: Judoon Enforcers — "No more than one + // creature can attack you each combat." Player 1 controls the static; + // player 0 (active) may send at most one attacker at player 1. + let mut state = setup(); + let enforcers = create_creature(&mut state, PlayerId(1), "Judoon Enforcers", 8, 8); + state + .objects + .get_mut(&enforcers) + .unwrap() + .static_definitions + .push(StaticDefinition::new(StaticMode::MaxAttackersEachCombat { + max: 1, + defender: Some(AttackDefenderScope::Controller), + })); + let a = create_creature(&mut state, PlayerId(0), "Bear", 2, 2); + let b = create_creature(&mut state, PlayerId(0), "Elk", 2, 2); + + // One attacker against player 1: legal. + assert!(validate_per_defender_attacker_caps( + &state, + &[(a, AttackTarget::Player(PlayerId(1)))] + ) + .is_ok()); + // Two attackers against player 1: illegal. + assert!(validate_per_defender_attacker_caps( + &state, + &[ + (a, AttackTarget::Player(PlayerId(1))), + (b, AttackTarget::Player(PlayerId(1))), + ], + ) + .is_err()); + // A defender-scoped cap must NOT register as a global per-combat cap — + // the two enforcement paths are independent (CR 508.1c). + assert_eq!(max_attackers_each_combat(&state), None); + } + #[test] fn cant_block_alone_rejects_sole_blocker() { // CR 506.5 + CR 509.1b: a CombatAlone(Block, NeedsCompanion) creature is diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index dc4e5963a2..d504fb1b74 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -11000,8 +11000,11 @@ mod tests { .to_string(), ); face.static_abilities.push( - StaticDefinition::new(StaticMode::MaxAttackersEachCombat { max: 1 }) - .description("No more than one creature can attack each combat.".to_string()), + StaticDefinition::new(StaticMode::MaxAttackersEachCombat { + max: 1, + defender: None, + }) + .description("No more than one creature can attack each combat.".to_string()), ); face.static_abilities.push( StaticDefinition::new(StaticMode::MaxBlockersEachCombat { max: 1 }) diff --git a/crates/engine/src/parser/oracle_static/evasion.rs b/crates/engine/src/parser/oracle_static/evasion.rs index 52da006dfd..fef5486f36 100644 --- a/crates/engine/src/parser/oracle_static/evasion.rs +++ b/crates/engine/src/parser/oracle_static/evasion.rs @@ -223,9 +223,23 @@ pub(crate) fn parse_max_combat_creatures_static(lower: &str) -> Option>("creature").parse(rest).ok()?; let (rest, _) = opt(tag::<_, _, OracleError<'_>>("s")).parse(rest).ok()?; let (rest, mode) = alt(( + // CR 508.5 + CR 802.1: "...can attack you each combat" is a + // defending-player-scoped cap (Judoon Enforcers) — only attacks + // declared against this static's controller are limited. Must precede + // the bare " can attack each combat" arm (longest match first). value( - StaticMode::MaxAttackersEachCombat { max }, - tag::<_, _, OracleError<'_>>(" can attack each combat"), + StaticMode::MaxAttackersEachCombat { + max, + defender: Some(AttackDefenderScope::Controller), + }, + tag::<_, _, OracleError<'_>>(" can attack you each combat"), + ), + value( + StaticMode::MaxAttackersEachCombat { + max, + defender: None, + }, + tag(" can attack each combat"), ), value( StaticMode::MaxBlockersEachCombat { max }, diff --git a/crates/engine/src/parser/oracle_static/mod.rs b/crates/engine/src/parser/oracle_static/mod.rs index f123c779df..e9e3abb08a 100644 --- a/crates/engine/src/parser/oracle_static/mod.rs +++ b/crates/engine/src/parser/oracle_static/mod.rs @@ -56,11 +56,11 @@ mod prelude { pub(super) use crate::types::mana::{ManaColor, ManaCost, ManaType}; pub(super) use crate::types::phase::Phase; pub(super) use crate::types::statics::{ - ActivationExemption, AdditionalCostTaxAction, BlockExceptionKind, CastFreeOrigin, - CastFrequency, CastingProhibitionCondition, CombatAloneAction, CombatAloneRequirement, - CostModifyMode, CostPaymentProhibition, CrewAction, CrewContributionKind, ExileCardPool, - ExileCastCost, ExileCastTiming, HandSizeModification, ProhibitionScope, StaticMode, - TriggerCause, + ActivationExemption, AdditionalCostTaxAction, AttackDefenderScope, BlockExceptionKind, + CastFreeOrigin, CastFrequency, CastingProhibitionCondition, CombatAloneAction, + CombatAloneRequirement, CostModifyMode, CostPaymentProhibition, CrewAction, + CrewContributionKind, ExileCardPool, ExileCastCost, ExileCastTiming, HandSizeModification, + ProhibitionScope, StaticMode, TriggerCause, }; pub(super) use crate::types::zones::Zone; } diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 906d59ed6c..22d1a5f2ba 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -9652,13 +9652,38 @@ fn static_must_attack_each_combat_if_able() { #[test] fn static_no_more_than_one_creature_can_attack_each_combat() { let def = parse_static_line("No more than one creature can attack each combat.").unwrap(); - assert_eq!(def.mode, StaticMode::MaxAttackersEachCombat { max: 1 }); + assert_eq!( + def.mode, + StaticMode::MaxAttackersEachCombat { + max: 1, + defender: None + } + ); } #[test] fn static_no_more_than_two_creatures_can_attack_each_combat() { let def = parse_static_line("No more than two creatures can attack each combat.").unwrap(); - assert_eq!(def.mode, StaticMode::MaxAttackersEachCombat { max: 2 }); + assert_eq!( + def.mode, + StaticMode::MaxAttackersEachCombat { + max: 2, + defender: None + } + ); +} + +#[test] +fn static_no_more_than_one_creature_can_attack_you_each_combat() { + // CR 508.5 + CR 802.1: Judoon Enforcers — defender-scoped attacker cap. + let def = parse_static_line("No more than one creature can attack you each combat.").unwrap(); + assert_eq!( + def.mode, + StaticMode::MaxAttackersEachCombat { + max: 1, + defender: Some(AttackDefenderScope::Controller), + } + ); } #[test] diff --git a/crates/engine/src/types/statics.rs b/crates/engine/src/types/statics.rs index 97c2bbd90b..f8a0b4a03c 100644 --- a/crates/engine/src/types/statics.rs +++ b/crates/engine/src/types/statics.rs @@ -606,6 +606,19 @@ pub enum CombatAloneRequirement { MustBeSole, } +/// CR 508.5 + CR 802.1: Which defending player a `MaxAttackersEachCombat` cap +/// restricts. `MaxAttackersEachCombat { defender: None }` is a global cap; +/// `Some(_)` narrows the cap to attacks declared against a specific defending +/// player, leaving attacks against other players unrestricted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AttackDefenderScope { + /// CR 109.5: "you" — the controller of the permanent carrying this static + /// (Judoon Enforcers: "No more than one creature can attack you each + /// combat"). Resolved against the static source's controller at the + /// declare-attackers step. + Controller, +} + /// All static ability modes from Forge's static ability registry. /// Matched case-sensitively against Forge mode strings. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -614,10 +627,18 @@ pub enum StaticMode { CantAttack, CantBlock, CantAttackOrBlock, - /// CR 508.1d: No more than `max` creatures can be declared as attackers - /// each combat. + /// CR 508.1c: No more than `max` creatures can be declared as attackers + /// each combat. `defender` scopes *which declarations* the cap restricts: + /// `None` is a global per-combat cap (no more than `max` creatures can + /// attack at all); `Some(AttackDefenderScope::Controller)` is a + /// defending-player cap ("no more than `max` creatures can attack *you* + /// each combat" — Judoon Enforcers), restricting only attackers whose + /// defending player (CR 508.5) is this static's controller, so opponents + /// may still be attacked freely (CR 802.1 multiplayer range of influence). MaxAttackersEachCombat { max: u32, + #[serde(default)] + defender: Option, }, /// CR 509.1c: No more than `max` creatures can be declared as blockers /// each combat. @@ -1486,8 +1507,11 @@ impl Hash for StaticMode { StaticMode::ExtraBlockers { count } => count.hash(state), StaticMode::MustBlockAttacker { attacker } => attacker.hash(state), StaticMode::MustAttackPlayer { player } => player.hash(state), - StaticMode::MaxAttackersEachCombat { max } - | StaticMode::MaxBlockersEachCombat { max } => max.hash(state), + StaticMode::MaxAttackersEachCombat { max, defender } => { + max.hash(state); + defender.hash(state); + } + StaticMode::MaxBlockersEachCombat { max } => max.hash(state), // CR 502.3: filter is a non-Hash TargetFilter; hash the enum // discriminant alongside the cap so creature/artifact/land caps // with the same max don't collide. @@ -1705,9 +1729,12 @@ impl fmt::Display for StaticMode { StaticMode::CantAttack => write!(f, "CantAttack"), StaticMode::CantBlock => write!(f, "CantBlock"), StaticMode::CantAttackOrBlock => write!(f, "CantAttackOrBlock"), - StaticMode::MaxAttackersEachCombat { max } => { - write!(f, "MaxAttackersEachCombat({max})") - } + StaticMode::MaxAttackersEachCombat { max, defender } => match defender { + None => write!(f, "MaxAttackersEachCombat({max})"), + Some(AttackDefenderScope::Controller) => { + write!(f, "MaxAttackersEachCombat({max},Controller)") + } + }, StaticMode::MaxBlockersEachCombat { max } => { write!(f, "MaxBlockersEachCombat({max})") } @@ -2020,10 +2047,9 @@ impl FromStr for StaticMode { "LinkedCollectionCounterPlayPermission" => { StaticMode::LinkedCollectionCounterPlayPermission } - s if parse_static_mode_u32_arg(s, "MaxAttackersEachCombat").is_some() => { - StaticMode::MaxAttackersEachCombat { - max: parse_static_mode_u32_arg(s, "MaxAttackersEachCombat").unwrap(), - } + s if parse_max_attackers_each_combat_args(s).is_some() => { + let (max, defender) = parse_max_attackers_each_combat_args(s).unwrap(); + StaticMode::MaxAttackersEachCombat { max, defender } } s if parse_static_mode_u32_arg(s, "MaxBlockersEachCombat").is_some() => { StaticMode::MaxBlockersEachCombat { @@ -2540,6 +2566,22 @@ fn parse_static_mode_u32_arg(s: &str, prefix: &str) -> Option { .ok() } +/// Round-trip the `MaxAttackersEachCombat(max[,Controller])` Display form back +/// to its `(max, defender)` arguments. Mirrors the two `fmt::Display` branches. +fn parse_max_attackers_each_combat_args(s: &str) -> Option<(u32, Option)> { + let args = s + .strip_prefix("MaxAttackersEachCombat")? + .strip_prefix('(')? + .strip_suffix(')')?; + match args.split_once(',') { + None => Some((args.parse().ok()?, None)), + Some((max, "Controller")) => { + Some((max.parse().ok()?, Some(AttackDefenderScope::Controller))) + } + Some(_) => None, + } +} + /// CR 509.1b: Canonical attacker filter for "can block only creatures with flying." pub fn block_only_creatures_with_flying_filter() -> TargetFilter { use super::ability::{FilterProp, TypedFilter}; @@ -2772,7 +2814,14 @@ mod tests { StaticMode::CantAttack, StaticMode::ExtraBlockers { count: None }, StaticMode::ExtraBlockers { count: Some(1) }, - StaticMode::MaxAttackersEachCombat { max: 2 }, + StaticMode::MaxAttackersEachCombat { + max: 2, + defender: None, + }, + StaticMode::MaxAttackersEachCombat { + max: 1, + defender: Some(AttackDefenderScope::Controller), + }, StaticMode::MaxBlockersEachCombat { max: 3 }, StaticMode::CantBeBlockedByMoreThan { max: 2 }, StaticMode::RevealTopOfLibrary { all_players: false },