diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index 5446607c92..1e67ff7560 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -1108,6 +1108,7 @@ fn casting_restriction_applies( Phase::DeclareBlockers | Phase::CombatDamage | Phase::EndCombat ), CastingRestriction::BeforeCombatDamage => is_before_combat_damage(state.phase), + CastingRestriction::AfterBlockersDeclared => is_after_blockers_declared(state), CastingRestriction::AfterCombat => matches!( state.phase, Phase::EndCombat | Phase::PostCombatMain | Phase::End | Phase::Cleanup @@ -1943,6 +1944,24 @@ fn is_before_combat_damage(phase: Phase) -> bool { ) } +/// CR 509.1 + CR 506.7f: "after blockers are declared" is true only once the +/// declare-blockers turn-based action (CR 509.1) has actually recorded a +/// declaration this combat — not merely that the phase label reads +/// DeclareBlockers. `CombatState::blockers_declared_by` is populated when a +/// defending player declares blockers (including a declaration of zero blockers) +/// and is never cleared mid-combat, so the window stays open through combat +/// damage (CR 510) and end of combat (CR 511). Gating on the phase label alone +/// would wrongly admit the pre-declaration declare-blockers priority window and +/// combats where the declare-blockers step was skipped (CR 506.7f: the spell +/// can't be cast in that combat at all). +fn is_after_blockers_declared(state: &crate::types::game_state::GameState) -> bool { + state.phase.is_combat() + && state + .combat + .as_ref() + .is_some_and(|combat| !combat.blockers_declared_by.is_empty()) +} + fn you_control_creature_with_keyword( state: &crate::types::game_state::GameState, player: PlayerId, @@ -2852,6 +2871,81 @@ mod tests { )); } + #[test] + fn after_blockers_declared_requires_the_declare_blockers_turn_based_action() { + // CR 509.1 + CR 506.7f: the window opens only once the declare-blockers + // turn-based action has actually run this combat (tracked by + // `CombatState::blockers_declared_by`) — the phase label alone is not + // enough. It must exclude the pre-declaration declare-blockers priority + // window and any combat where the declare-blockers step was skipped. + use crate::game::combat::CombatState; + let restriction = CastingRestriction::AfterBlockersDeclared; + let applies = |state: &crate::types::game_state::GameState| { + casting_restriction_applies(state, PlayerId(0), ObjectId(1), &restriction) + }; + let declared = || CombatState { + blockers_declared_by: vec![PlayerId(1)], + ..CombatState::default() + }; + let mut state = crate::types::game_state::GameState::new_two_player(42); + + // Pre-declaration: in the declare-blockers step but the turn-based + // action hasn't recorded a declaration yet — excluded. + state.phase = Phase::DeclareBlockers; + state.combat = Some(CombatState::default()); + assert!( + !applies(&state), + "the pre-declaration declare-blockers priority window must be excluded" + ); + + // Post-declaration in the same step, and persisting through combat + // damage and end of combat — included. (blockers_declared_by is never + // cleared mid-combat.) + for phase in [ + Phase::DeclareBlockers, + Phase::CombatDamage, + Phase::EndCombat, + ] { + state.phase = phase; + state.combat = Some(declared()); + assert!( + applies(&state), + "{phase:?} after the declare-blockers action must be included" + ); + } + + // CR 506.7f: a combat where the declare-blockers step was skipped never + // records a declaration, so even combat damage / end of combat are + // excluded. + for phase in [Phase::CombatDamage, Phase::EndCombat] { + state.phase = phase; + state.combat = Some(CombatState::default()); + assert!( + !applies(&state), + "{phase:?} in a skipped-declare-blockers combat must be excluded" + ); + } + + // Before blockers are declared: begin-combat and declare-attackers have + // no recorded declaration — excluded. + for phase in [Phase::BeginCombat, Phase::DeclareAttackers] { + state.phase = phase; + state.combat = Some(CombatState::default()); + assert!( + !applies(&state), + "{phase:?} precedes blocker declaration and must be excluded" + ); + } + + // Outside combat entirely — excluded even if a stale declaration flag + // somehow survived (the phase guard closes the window). + state.phase = Phase::PostCombatMain; + state.combat = Some(declared()); + assert!(!applies(&state), "post-combat main must be excluded"); + state.combat = None; + assert!(!applies(&state), "no combat state must be excluded"); + } + #[test] fn evaluates_opponent_searched_library_this_turn_condition() { let mut state = crate::types::game_state::GameState::new_two_player(42); diff --git a/crates/engine/src/parser/oracle_casting.rs b/crates/engine/src/parser/oracle_casting.rs index 230776e5c1..cbf8984d8c 100644 --- a/crates/engine/src/parser/oracle_casting.rs +++ b/crates/engine/src/parser/oracle_casting.rs @@ -713,12 +713,9 @@ fn parse_before_phrase(input: &str) -> nom::IResult<&str, CastingRestriction, Or } /// Sub-dispatch for "after [rest]" — blockers declared, combat. Mirror of -/// `parse_before_phrase`: `after blockers are declared` opens the post-blockers -/// combat window (CR 509.1, CR 510.1, and CR 511.1), while `after combat` (folded in from -/// the former standalone leaf) is the post-combat-phase window. Backs the class -/// printing "Cast this spell only during combat after blockers are declared." -/// (Aleatory, Chaotic Strike, Curtain of Light, Flash Foliage) alongside the -/// separately-scanned `DuringCombat`. +/// `parse_before_phrase`: "after blockers are declared" is the complement of +/// "before blockers are declared" within the combat phase (Aleatory, Chaotic +/// Strike, Curtain of Light, Flash Foliage). fn parse_after_phrase(input: &str) -> nom::IResult<&str, CastingRestriction, OracleError<'_>> { alt(( value( @@ -987,6 +984,25 @@ mod tests { assert!(restrictions.contains(&CastingRestriction::AfterCombat)); } + #[test] + fn spell_cast_restriction_handles_combat_after_blockers() { + // Aleatory, Chaotic Strike, Curtain of Light, Flash Foliage all print + // this exact line. The "after blockers are declared" qualifier must be + // captured, not silently dropped, or the spell becomes castable during + // all of combat (CR 601.3 timing violation). + let restrictions = parse_casting_restriction_line( + "Cast this spell only during combat after blockers are declared.", + ) + .expect("restrictions should parse"); + assert!(restrictions.contains(&CastingRestriction::DuringCombat)); + assert!(restrictions.contains(&CastingRestriction::AfterBlockersDeclared)); + // "after combat" must still parse to the distinct AfterCombat window. + let after_combat = parse_casting_restriction_line("Cast this spell only after combat.") + .expect("restrictions should parse"); + assert!(after_combat.contains(&CastingRestriction::AfterCombat)); + assert!(!after_combat.contains(&CastingRestriction::AfterBlockersDeclared)); + } + #[test] fn parse_additional_cost_optional_blight() { let lower = "as an additional cost to cast this spell, you may blight 1."; diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index bf48d6d1f1..496f64fc82 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -15097,6 +15097,7 @@ pub enum CastingRestriction { /// combat phase (CR 506.1); enforced in `restrictions.rs`. AfterBlockersDeclared, BeforeCombatDamage, + AfterBlockersDeclared, AfterCombat, RequiresCondition { condition: Option,