Skip to content
Open
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
121 changes: 106 additions & 15 deletions crates/engine/src/game/combat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<u32> {
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
Expand Down Expand Up @@ -2004,6 +2064,10 @@ pub fn declare_attackers_with_bands(
) -> Result<(), String> {
let attacker_ids: Vec<ObjectId> = 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)?;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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());
Comment on lines +3425 to +3433

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] Expand test coverage to verify defender-scoped restriction in multiplayer scenarios.

Why it matters: The test currently only verifies that attacking the protected player (Player 1) is restricted, but it does not assert that attacking other players (e.g., Player 2) remains unrestricted by the defender-scoped cap. Adding this assertion ensures that the cap is correctly scoped to the defender and does not act as a global cap in multiplayer games.

Suggested fix: Add an assertion verifying that attacking an unprotected player is legal.

Suggested change
// 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());
// 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());
// Two attackers against player 2 (unprotected): legal.
assert!(validate_per_defender_attacker_caps(
&state,
&[
(a, AttackTarget::Player(PlayerId(2))),
(b, AttackTarget::Player(PlayerId(2))),
],
)
.is_ok());

// 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
Expand Down
7 changes: 5 additions & 2 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
18 changes: 16 additions & 2 deletions crates/engine/src/parser/oracle_static/evasion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,23 @@ pub(crate) fn parse_max_combat_creatures_static(lower: &str) -> Option<StaticMod
let (rest, _) = tag::<_, _, OracleError<'_>>("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 },
Expand Down
10 changes: 5 additions & 5 deletions crates/engine/src/parser/oracle_static/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
29 changes: 27 additions & 2 deletions crates/engine/src/parser/oracle_static/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
73 changes: 61 additions & 12 deletions crates/engine/src/types/statics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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<AttackDefenderScope>,
},
/// CR 509.1c: No more than `max` creatures can be declared as blockers
/// each combat.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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})")
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2540,6 +2566,22 @@ fn parse_static_mode_u32_arg(s: &str, prefix: &str) -> Option<u32> {
.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<AttackDefenderScope>)> {
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};
Expand Down Expand Up @@ -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 },
Expand Down
Loading