Skip to content
94 changes: 94 additions & 0 deletions crates/engine/src/game/restrictions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1070,7 +1070,7 @@
source_id: ObjectId,
restriction: &CastingRestriction,
) -> bool {
match restriction {

Check failure on line 1073 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / WASM compile check

non-exhaustive patterns: `&CastingRestriction::AfterBlockersDeclared` not covered

Check failure on line 1073 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / Card data (generate, validate, coverage)

non-exhaustive patterns: `&CastingRestriction::AfterBlockersDeclared` not covered

Check failure on line 1073 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

non-exhaustive patterns: `&types::ability::CastingRestriction::AfterBlockersDeclared` not covered

Check failure on line 1073 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 2/2)

non-exhaustive patterns: `&ability::CastingRestriction::AfterBlockersDeclared` not covered

Check failure on line 1073 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 2/2)

non-exhaustive patterns: `&CastingRestriction::AfterBlockersDeclared` not covered

Check failure on line 1073 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 1/2)

non-exhaustive patterns: `&ability::CastingRestriction::AfterBlockersDeclared` not covered

Check failure on line 1073 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 1/2)

non-exhaustive patterns: `&CastingRestriction::AfterBlockersDeclared` not covered
// CR 307.1: A player may cast a sorcery during a main phase of their turn when the stack is empty.
CastingRestriction::AsSorcery => is_sorcery_speed_window(state, player),
CastingRestriction::DuringCombat => state.phase.is_combat(),
Expand Down Expand Up @@ -1108,6 +1108,7 @@
Phase::DeclareBlockers | Phase::CombatDamage | Phase::EndCombat
),
CastingRestriction::BeforeCombatDamage => is_before_combat_damage(state.phase),
CastingRestriction::AfterBlockersDeclared => is_after_blockers_declared(state),

Check failure on line 1111 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / WASM compile check

unreachable pattern

Check failure on line 1111 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / Card data (generate, validate, coverage)

unreachable pattern

Check failure on line 1111 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

unreachable pattern

Check failure on line 1111 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 2/2)

unreachable pattern

Check failure on line 1111 in crates/engine/src/game/restrictions.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 1/2)

unreachable pattern
CastingRestriction::AfterCombat => matches!(
state.phase,
Phase::EndCombat | Phase::PostCombatMain | Phase::End | Phase::Cleanup
Expand Down Expand Up @@ -1943,6 +1944,24 @@
)
}

/// 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,
Expand Down Expand Up @@ -2852,6 +2871,81 @@
));
}

#[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);
Expand Down
28 changes: 22 additions & 6 deletions crates/engine/src/parser/oracle_casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -713,12 +713,9 @@
}

/// 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<'_>> {

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] Missing mandatory CR annotation. Evidence: crates/engine/src/parser/oracle_casting.rs:719.

Why it matters: According to the Repository Style Guide (R6), every rules-touching line of engine code in parser/ must carry a comment of the form CR <number>: <description>.

Suggested fix: Add a CR 506.6f annotation to the parse_after_phrase parser function.

/// CR 506.6f: Parses the "after blockers are declared" casting restriction.
fn parse_after_phrase(input: &str) -> nom::IResult<&str, CastingRestriction, OracleError<'_>> {
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

alt((
value(
Expand Down Expand Up @@ -987,6 +984,25 @@
assert!(restrictions.contains(&CastingRestriction::AfterCombat));
}

#[test]
fn spell_cast_restriction_handles_combat_after_blockers() {

Check failure on line 988 in crates/engine/src/parser/oracle_casting.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

the name `spell_cast_restriction_handles_combat_after_blockers` is defined multiple times

Check failure on line 988 in crates/engine/src/parser/oracle_casting.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 2/2)

the name `spell_cast_restriction_handles_combat_after_blockers` is defined multiple times

Check failure on line 988 in crates/engine/src/parser/oracle_casting.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 1/2)

the name `spell_cast_restriction_handles_combat_after_blockers` is defined multiple times
// 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.";
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/types/ability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15097,6 +15097,7 @@ pub enum CastingRestriction {
/// combat phase (CR 506.1); enforced in `restrictions.rs`.
AfterBlockersDeclared,
BeforeCombatDamage,
AfterBlockersDeclared,

Check failure on line 15100 in crates/engine/src/types/ability.rs

View workflow job for this annotation

GitHub Actions / WASM compile check

unreachable pattern

Check failure on line 15100 in crates/engine/src/types/ability.rs

View workflow job for this annotation

GitHub Actions / WASM compile check

the name `AfterBlockersDeclared` is defined multiple times

Check failure on line 15100 in crates/engine/src/types/ability.rs

View workflow job for this annotation

GitHub Actions / Card data (generate, validate, coverage)

unreachable pattern

Check failure on line 15100 in crates/engine/src/types/ability.rs

View workflow job for this annotation

GitHub Actions / Card data (generate, validate, coverage)

the name `AfterBlockersDeclared` is defined multiple times

Check failure on line 15100 in crates/engine/src/types/ability.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

unreachable pattern

Check failure on line 15100 in crates/engine/src/types/ability.rs

View workflow job for this annotation

GitHub Actions / Rust lint (fmt, clippy, parser gate)

the name `AfterBlockersDeclared` is defined multiple times

Check failure on line 15100 in crates/engine/src/types/ability.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 2/2)

unreachable pattern

Check failure on line 15100 in crates/engine/src/types/ability.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 2/2)

the name `AfterBlockersDeclared` is defined multiple times

Check failure on line 15100 in crates/engine/src/types/ability.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 1/2)

unreachable pattern

Check failure on line 15100 in crates/engine/src/types/ability.rs

View workflow job for this annotation

GitHub Actions / Rust tests (shard 1/2)

the name `AfterBlockersDeclared` is defined multiple times
AfterCombat,
RequiresCondition {
condition: Option<ParsedCondition>,
Expand Down
Loading