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
2 changes: 1 addition & 1 deletion crates/engine/src/ai_support/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,7 @@ impl LegalityPoisonGates {
| StaticMode::BlockRestriction { .. }
| StaticMode::MustBlock
| StaticMode::MustBlockAttacker { .. }
| StaticMode::MustBeBlocked
| StaticMode::MustBeBlocked { .. }
| StaticMode::MustBeBlockedByAll
| StaticMode::MaxBlockersEachCombat { .. }
| StaticMode::ExtraBlockers { .. }
Expand Down
285 changes: 240 additions & 45 deletions crates/engine/src/game/combat.rs

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ fn is_data_carrying_static(mode: &StaticMode) -> bool {
| StaticMode::MaximumHandSize { .. }
| StaticMode::StepEndUnspentMana { .. }
| StaticMode::CantBeBlockedBy { .. }
// CR 509.1c: MustBeBlocked carries an optional blocker `TargetFilter`
// (None = any blocker; Some = "must be blocked by a <quality>"). The
// None shape is no longer registry-keyed (the variant is now
// parameterized with a non-Hash TargetFilter); runtime enforcement is
// direct-match in combat.rs declare-blockers validation (mirrors
// CantBeBlockedBy).
| StaticMode::MustBeBlocked { .. }
// CR 509.1b: CantBeBlockedExceptBy carries `kind`.
| StaticMode::CantBeBlockedExceptBy { .. }
// CR 702.39a + CR 509.1c: MustBlockAttacker carries the `ObjectId` of
Expand Down Expand Up @@ -10368,10 +10375,10 @@ mod tests {
AbilityKind::Spell,
Effect::GenericEffect {
static_abilities: vec![StaticDefinition {
mode: StaticMode::MustBeBlocked,
mode: StaticMode::MustBeBlocked { by: None },
affected: None,
modifications: vec![ContinuousModification::AddStaticMode {
mode: StaticMode::MustBeBlocked,
mode: StaticMode::MustBeBlocked { by: None },
}],
condition: None,
per_player_condition: None,
Expand Down
7 changes: 5 additions & 2 deletions crates/engine/src/game/static_abilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,11 @@ pub fn build_static_registry() -> HashMap<StaticMode, StaticAbilityHandler> {
registry.insert(StaticMode::Lifelink, handle_static_lifelink);
registry.insert(StaticMode::CantTap, handle_rule_mod);
registry.insert(StaticMode::CantUntap, handle_rule_mod);
// CR 509.1c: MustBeBlocked — this creature must be blocked if able.
registry.insert(StaticMode::MustBeBlocked, handle_rule_mod);
// CR 509.1c: MustBeBlocked is now a parameterized, data-carrying variant
// (`by: Option<TargetFilter>`) — it cannot be an exact HashMap key, so it is
// NOT registry-keyed (mirrors CantBeBlockedBy). Coverage support is via
// coverage::is_data_carrying_static; runtime enforcement is direct-match in
// combat.rs declare-blockers validation.
// CR 509.1c: MustBeBlockedByAll — every creature able to block this creature
// must do so ("All creatures able to block ~ do so"; enforced in combat.rs).
registry.insert(StaticMode::MustBeBlockedByAll, handle_rule_mod);
Expand Down
21 changes: 11 additions & 10 deletions crates/engine/src/parser/oracle_effect/imperative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9605,16 +9605,17 @@ fn lower_imperative_family_effect(ast: ImperativeFamilyAst) -> Effect {
// CR 509.1c: Must be blocked — grant transient MustBeBlocked static via GenericEffect.
// Uses AddStaticMode so the mode propagates through the layer system to
// static_definitions, where combat.rs checks it.
ImperativeFamilyAst::MustBeBlocked => {
Effect::GenericEffect {
static_abilities: vec![StaticDefinition::new(StaticMode::MustBeBlocked)
.modifications(vec![ContinuousModification::AddStaticMode {
mode: StaticMode::MustBeBlocked,
}])],
duration: Some(Duration::UntilEndOfTurn),
target: None,
}
}
ImperativeFamilyAst::MustBeBlocked => Effect::GenericEffect {
static_abilities: vec![
StaticDefinition::new(StaticMode::MustBeBlocked { by: None }).modifications(vec![
ContinuousModification::AddStaticMode {
mode: StaticMode::MustBeBlocked { by: None },
},
]),
],
duration: Some(Duration::UntilEndOfTurn),
target: None,
},
ImperativeFamilyAst::Investigate => Effect::Investigate,
ImperativeFamilyAst::Learn => Effect::Learn,
// CR 701.40a: Default subject is the controller ("you manifest..."). Subject
Expand Down
13 changes: 8 additions & 5 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33853,7 +33853,10 @@ mod tests {
duration,
..
} => {
assert_eq!(static_abilities[0].mode, StaticMode::MustBeBlocked);
assert_eq!(
static_abilities[0].mode,
StaticMode::MustBeBlocked { by: None }
);
assert_eq!(*duration, Some(Duration::UntilEndOfTurn));
assert_eq!(
static_abilities[0].affected,
Expand Down Expand Up @@ -38944,7 +38947,7 @@ mod tests {
assert!(
matches!(&e, Effect::GenericEffect { static_abilities, .. }
if static_abilities.iter().any(|sd|
sd.mode == crate::types::statics::StaticMode::MustBeBlocked
matches!(sd.mode, crate::types::statics::StaticMode::MustBeBlocked { by: None })
)
),
"Expected GenericEffect with MustBeBlocked, got {:?}",
Expand All @@ -38959,7 +38962,7 @@ mod tests {
assert!(
matches!(&e, Effect::GenericEffect { static_abilities, .. }
if static_abilities.iter().any(|sd|
sd.mode == crate::types::statics::StaticMode::MustBeBlocked
matches!(sd.mode, crate::types::statics::StaticMode::MustBeBlocked { by: None })
)
),
"Expected GenericEffect with MustBeBlocked, got {:?}",
Expand Down Expand Up @@ -38988,7 +38991,7 @@ mod tests {
assert!(
matches!(&*sub.effect, Effect::GenericEffect { static_abilities, .. }
if static_abilities.iter().any(|sd|
sd.mode == crate::types::statics::StaticMode::MustBeBlocked
matches!(sd.mode, crate::types::statics::StaticMode::MustBeBlocked { by: None })
)
),
"Expected sub_ability GenericEffect with MustBeBlocked, got {:?}",
Expand Down Expand Up @@ -48385,7 +48388,7 @@ mod tests {
assert!(
matches!(&*sub.effect, Effect::GenericEffect { static_abilities, .. }
if static_abilities.iter().any(|sd|
sd.mode == crate::types::statics::StaticMode::MustBeBlocked
matches!(sd.mode, crate::types::statics::StaticMode::MustBeBlocked { by: None })
)
),
"expected sub_ability GenericEffect with MustBeBlocked, got {:?}",
Expand Down
12 changes: 7 additions & 5 deletions crates/engine/src/parser/oracle_effect/subject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -982,11 +982,13 @@ fn try_parse_subject_restriction_clause(
let affected = static_affected_for_application(&application);
return Some(ParsedEffectClause {
effect: Effect::GenericEffect {
static_abilities: vec![StaticDefinition::new(StaticMode::MustBeBlocked)
.affected(affected)
.modifications(vec![ContinuousModification::AddStaticMode {
mode: StaticMode::MustBeBlocked,
}])],
static_abilities: vec![StaticDefinition::new(StaticMode::MustBeBlocked {
by: None,
})
.affected(affected)
.modifications(vec![ContinuousModification::AddStaticMode {
mode: StaticMode::MustBeBlocked { by: None },
}])],
duration: Some(Duration::UntilEndOfTurn),
target: application.target,
},
Expand Down
34 changes: 28 additions & 6 deletions crates/engine/src/parser/oracle_static/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2676,13 +2676,35 @@ pub(crate) fn parse_static_line_inner(
}
}

// --- "must be blocked if able" (CR 509.1b) ---
// --- "must be blocked [by <quality>] if able" (CR 509.1c) ---
if nom_primitives::scan_contains(tp.lower, "must be blocked") {
return Some(
StaticDefinition::new(StaticMode::MustBeBlocked)
.affected(TargetFilter::SelfRef)
.description(text.to_string()),
);
// CR 509.1c: classify the OPTIONAL "by <quality>" conjunct so a present
// quality is never silently weakened to the bare "any blocker" (None)
// requirement. Mirrors the attached-grant paths (grammar.rs / shared.rs)
// which distinguish the same three cases via the shared conjunct helper:
// * Recognized quality → typed `MustBeBlocked { by: Some(filter) }`.
// * Unrecognized quality → leave the line Unimplemented (`return None`);
// emitting `by: None` here would force a block by ANY creature and
// drop the quality restriction. Falling through surfaces the gap to
// coverage instead of weakening the requirement.
// * No quality (bare "must be blocked if able") → `by: None`.
match extract_must_be_blocked_by_conjunct(tp.lower) {
Some(MustBeBlockedByConjunct::Recognized(filter)) => {
return Some(
StaticDefinition::new(StaticMode::MustBeBlocked { by: Some(filter) })
.affected(TargetFilter::SelfRef)
.description(text.to_string()),
);
}
Some(MustBeBlockedByConjunct::Unrecognized(_)) => return None,
None => {
return Some(
StaticDefinition::new(StaticMode::MustBeBlocked { by: None })
.affected(TargetFilter::SelfRef)
.description(text.to_string()),
);
}
}
}

// --- "can't gain life" (CR 119.7) ---
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/parser/oracle_static/evasion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ pub(crate) fn try_split_and_must_attack_block(text: &str) -> Option<Vec<StaticDe
)),
),
value(
vec![StaticMode::MustBeBlocked],
vec![StaticMode::MustBeBlocked { by: None }],
alt((
tag::<_, _, VE>("must be blocked each combat if able"),
tag("must be blocked if able"),
Expand Down
Loading
Loading