diff --git a/crates/engine/src/analysis/ability_graph.rs b/crates/engine/src/analysis/ability_graph.rs index 8c34d267db..313dcb1f2f 100644 --- a/crates/engine/src/analysis/ability_graph.rs +++ b/crates/engine/src/analysis/ability_graph.rs @@ -1002,6 +1002,7 @@ fn effect_projection(effect: &Effect) -> Projection { | Effect::SetDayNight { .. } | Effect::GiveControl { .. } | Effect::RemoveFromCombat { .. } + | Effect::BecomeBlocked { .. } | Effect::ApplyPerpetual { .. } | Effect::Intensify { .. } | Effect::DraftFromSpellbook { .. } diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index c56751fa47..b5148c6b7d 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2693,6 +2693,7 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::Detain { target } | Effect::SetRoomDoorLock { target, .. } | Effect::RemoveFromCombat { target } + | Effect::BecomeBlocked { target } | Effect::ApplyPerpetual { target, .. } | Effect::TurnFaceUp { target } | Effect::TurnFaceDown { target, .. } @@ -4977,6 +4978,15 @@ fn rw_effect( flag_legacy_write_target(&mut p, target); (p, None) } + // CR 509.1h: making an attacking creature become blocked writes the + // combat `blocked` flag — a turn/combat-structure write, idempotent on a + // non-attacker/already-blocked target (mirrors RemoveFromCombat above, + // NOT the maximal-conservative fallback). + Effect::BecomeBlocked { target } => { + let mut p = ext_write(StateKind::TurnStructure); + flag_legacy_write_target(&mut p, target); + (p, None) + } Effect::AssembleContraptions { count } => { let mut p = ext_write(StateKind::Other); p.merge(rw_quantity_expr(count)); diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 72df0cac88..f72e0fce41 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -947,6 +947,11 @@ fn scan_effect(x: &Effect) -> Axes { acc = acc.or(scan_target_filter(target)); acc } + Effect::BecomeBlocked { target } => { + let mut acc = Axes::NONE; + acc = acc.or(scan_target_filter(target)); + acc + } Effect::SetClassLevel { level: _ } => Axes::NONE, Effect::CreateDelayedTrigger { .. } => Axes::CONSERVATIVE, Effect::AddTargetReplacement { .. } => Axes::CONSERVATIVE, @@ -3417,6 +3422,7 @@ fn effect_resolution_choice_freedom(e: &Effect) -> ResolutionChoiceFreedom { | Effect::BecomePrepared { .. } | Effect::BecomeUnprepared { .. } | Effect::BecomeSaddled { .. } + | Effect::BecomeBlocked { .. } | Effect::SetClassLevel { .. } | Effect::CreateDelayedTrigger { .. } | Effect::AddTargetReplacement { .. } diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index 98b82473d0..1e715f33c0 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -429,6 +429,25 @@ pub fn place_blocking(state: &mut GameState, blocker_id: ObjectId, attacker_id: true } +/// CR 509.1h: mark a current attacker as blocked purely by effect, without +/// assigning any blocking creature. The attacker becomes (and remains) blocked +/// even though `blocker_assignments` / `blocker_to_attacker` stay empty; per +/// CR 510.1c a blocked creature with no creatures blocking it assigns no combat +/// damage. Emits no event (the caller decides whether the CR 509.3c precondition +/// is met). Returns `false` if `oid` is not a current attacker. +pub fn mark_attacker_blocked(state: &mut GameState, oid: ObjectId) -> bool { + let Some(combat) = state.combat.as_mut() else { + return false; + }; + let Some(info) = combat.attackers.iter_mut().find(|a| a.object_id == oid) else { + return false; + }; + info.blocked = true; + // CR 613.1f: `FilterProp::Blocked` grants may now apply; re-evaluate layers. + state.layers_dirty.mark_full(); + true +} + /// Validate attacker declarations per CR 508.1. pub fn validate_attackers(state: &GameState, attacker_ids: &[ObjectId]) -> Result<(), String> { let active = state.active_player; diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index c0908bd1fd..b70f6101cb 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -3149,6 +3149,7 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { Effect::BecomePrepared { target } | Effect::BecomeUnprepared { target } | Effect::BecomeSaddled { target } + | Effect::BecomeBlocked { target } | Effect::PairWith { target } => { d.push(("target".into(), fmt_target(target))); } diff --git a/crates/engine/src/game/effects/become_blocked.rs b/crates/engine/src/game/effects/become_blocked.rs new file mode 100644 index 0000000000..9b5514417d --- /dev/null +++ b/crates/engine/src/game/effects/become_blocked.rs @@ -0,0 +1,211 @@ +//! CR 509.1h: effect-level "becomes blocked" resolver. +//! +//! Backs the ~21-card "target ... becomes blocked" class (e.g. Dazzling Beauty: +//! "Target unblocked attacking creature becomes blocked."). An effect can make an +//! attacking creature blocked even if no creature is declared as a blocker for it, +//! and it remains blocked even if it never had any blockers (CR 509.1h). Per +//! CR 510.1c a blocked creature with no creatures blocking it assigns no combat +//! damage — so the marked attacker deals no combat damage this turn. +//! +//! CR 509.3c: the `AttackerBecameBlockedByEffect` event is emitted only when the +//! attacker was an *unblocked* creature at the instant the effect resolved — the +//! precondition for "whenever ~ becomes blocked" triggers to fire from an effect. +//! CR 509.3d: because this is a distinct event (not `BlockersDeclared`), the +//! "becomes blocked BY A CREATURE" form and blocker-side "whenever ~ blocks" +//! triggers do NOT fire. + +use crate::game::combat; +use crate::types::ability::{ + Effect, EffectError, EffectKind, ResolvedAbility, TargetFilter, TargetRef, +}; +use crate::types::events::GameEvent; +use crate::types::game_state::GameState; +use crate::types::identifiers::ObjectId; + +/// Resolve the object target(s) of a `BecomeBlocked` effect. Mirrors the +/// `resolve_object_targets` chokepoint in `saddle.rs`: +/// - `SelfRef` → the source (a "~ becomes blocked" self-anaphor) +/// - a `ParentTarget`/context ref with no announced target → the source +/// (via `resolve_event_context_targets`) +/// - otherwise → the announced object targets (Dazzling Beauty's chosen attacker) +fn resolve_object_targets(state: &GameState, ability: &ResolvedAbility) -> Vec { + let Effect::BecomeBlocked { target } = &ability.effect else { + return Vec::new(); + }; + // CR 608.2c: the printed-name anaphor always resolves to the source. + if matches!(target, TargetFilter::SelfRef) { + return vec![ability.source_id]; + } + // CR 608.2c: a context ref (`ParentTarget`/`TriggeringSource`) resolves from + // the trigger event; empty for a plain targeted effect, which falls through + // to the announced targets below. + let event_targets = + crate::game::targeting::resolve_event_context_targets(state, target, ability.source_id); + if !event_targets.is_empty() { + return event_targets + .into_iter() + .filter_map(|t| match t { + TargetRef::Object(id) => Some(id), + TargetRef::Player(_) => None, + }) + .collect(); + } + ability + .targets + .iter() + .filter_map(|t| match t { + TargetRef::Object(id) => Some(*id), + TargetRef::Player(_) => None, + }) + .collect() +} + +/// CR 509.1h: resolver for `Effect::BecomeBlocked` — the target attacking +/// creature(s) become blocked with no blockers assigned. Emits +/// `AttackerBecameBlockedByEffect` for each attacker that was unblocked at the +/// instant of resolution (CR 509.3c), so "whenever ~ becomes blocked" triggers +/// fire. An illegal target (no longer a current attacker) is a no-op: CR 608.2b +/// fizzle is enforced upstream by `target_filter()`; a stale non-attacker id +/// simply fails `mark_attacker_blocked`. +pub fn resolve_become_blocked( + state: &mut GameState, + ability: &ResolvedAbility, + events: &mut Vec, +) -> Result<(), EffectError> { + for oid in resolve_object_targets(state, ability) { + // CR 509.3c: read whether the attacker was unblocked BEFORE mutating — + // the "becomes blocked" trigger fires from an effect only if the attacker + // was an unblocked creature at that time. + let was_unblocked = state + .combat + .as_ref() + .is_some_and(|c| c.attackers.iter().any(|a| a.object_id == oid && !a.blocked)); + if combat::mark_attacker_blocked(state, oid) && was_unblocked { + events.push(GameEvent::AttackerBecameBlockedByEffect { attacker: oid }); + } + } + events.push(GameEvent::EffectResolved { + kind: EffectKind::BecomeBlocked, + source_id: ability.source_id, + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::combat::{AttackTarget, AttackerInfo, CombatState}; + use crate::game::zones::create_object; + use crate::types::card_type::CoreType; + use crate::types::identifiers::CardId; + use crate::types::player::PlayerId; + use crate::types::zones::Zone; + + fn attacker_on_battlefield(state: &mut GameState, blocked: bool) -> ObjectId { + let id = create_object( + state, + CardId(1), + PlayerId(0), + "Test Attacker".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_power = Some(2); + obj.base_toughness = Some(2); + obj.power = Some(2); + obj.toughness = Some(2); + let combat = state.combat.get_or_insert_with(CombatState::default); + let mut info = AttackerInfo::new(id, AttackTarget::Player(PlayerId(1)), PlayerId(1)); + info.blocked = blocked; + combat.attackers.push(info); + id + } + + #[test] + fn become_blocked_marks_unblocked_attacker_and_emits_event() { + // CR 509.1h + CR 509.3c: an unblocked attacker becomes blocked and the + // effect-block event fires (precondition for "becomes blocked" triggers). + let mut state = GameState::new_two_player(42); + let attacker = attacker_on_battlefield(&mut state, false); + let ability = ResolvedAbility::new( + Effect::BecomeBlocked { + target: TargetFilter::Any, + }, + vec![TargetRef::Object(attacker)], + ObjectId(100), + PlayerId(0), + ); + let mut events = Vec::new(); + resolve_become_blocked(&mut state, &ability, &mut events).unwrap(); + + assert!(state + .combat + .as_ref() + .unwrap() + .attackers + .iter() + .any(|a| a.object_id == attacker && a.blocked)); + assert!(events.iter().any( + |e| matches!(e, GameEvent::AttackerBecameBlockedByEffect { attacker: a } if *a == attacker) + )); + } + + #[test] + fn become_blocked_already_blocked_does_not_emit_event() { + // CR 509.3c: an attacker already blocked (e.g. via place_blocking) was not + // an unblocked creature, so no effect-block event fires — the was_unblocked + // guard. This assertion fails if the guard is removed. + let mut state = GameState::new_two_player(42); + let attacker = attacker_on_battlefield(&mut state, true); + let ability = ResolvedAbility::new( + Effect::BecomeBlocked { + target: TargetFilter::Any, + }, + vec![TargetRef::Object(attacker)], + ObjectId(100), + PlayerId(0), + ); + let mut events = Vec::new(); + resolve_become_blocked(&mut state, &ability, &mut events).unwrap(); + assert!(!events + .iter() + .any(|e| matches!(e, GameEvent::AttackerBecameBlockedByEffect { .. }))); + // Reach-guard: the effect still ran (EffectResolved present) so the absence + // of the block event is not vacuous. + assert!(events.iter().any(|e| matches!( + e, + GameEvent::EffectResolved { + kind: EffectKind::BecomeBlocked, + .. + } + ))); + } + + #[test] + fn become_blocked_non_attacker_target_is_noop() { + // A stale / non-attacking target id fails mark_attacker_blocked and emits + // no block event, but the effect still resolves. + let mut state = GameState::new_two_player(42); + let non_attacker = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Bystander".to_string(), + Zone::Battlefield, + ); + let ability = ResolvedAbility::new( + Effect::BecomeBlocked { + target: TargetFilter::Any, + }, + vec![TargetRef::Object(non_attacker)], + ObjectId(100), + PlayerId(0), + ); + let mut events = Vec::new(); + resolve_become_blocked(&mut state, &ability, &mut events).unwrap(); + assert!(!events + .iter() + .any(|e| matches!(e, GameEvent::AttackerBecameBlockedByEffect { .. }))); + } +} diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 24b21bb86d..91f7d95da3 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -38,6 +38,7 @@ pub mod animate; pub mod attach; pub mod attractions; pub mod awaken; +pub mod become_blocked; pub mod become_copy; pub mod become_monarch; pub mod blight; @@ -3098,6 +3099,9 @@ pub fn resolve_effect( prepare::resolve_become_unprepared(state, ability, events) } Effect::BecomeSaddled { .. } => saddle::resolve(state, ability, events), + Effect::BecomeBlocked { .. } => { + become_blocked::resolve_become_blocked(state, ability, events) + } Effect::SetClassLevel { .. } => set_class_level::resolve(state, ability, events), Effect::CreateDelayedTrigger { .. } => delayed_trigger::resolve(state, ability, events), Effect::AddTargetReplacement { .. } => { diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index 3ddd025ced..1661e1976d 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -691,11 +691,12 @@ pub fn activate_ninjutsu( .ok_or("Specified creature is not an attacker")? .clone(); - let is_blocked = combat - .blocker_assignments - .get(&creature_to_return) - .is_some_and(|blockers| !blockers.is_empty()); - if is_blocked { + // CR 702.49a: ninjutsu returns an UNBLOCKED attacking creature. + // CR 509.1h: "blocked" is the attacker's `blocked` flag — a creature + // made blocked by an effect (with no blocker assignments) is blocked + // and thus ineligible, and one stays blocked even if its blockers are + // gone. Reading the flag (not `blocker_assignments`) closes both gaps. + if attacker_info.blocked { return Err("Attacker is blocked".to_string()); } @@ -2282,7 +2283,9 @@ mod tests { fn ninjutsu_fails_if_attacker_is_blocked() { let (mut state, attacker_id, ninja_id) = setup_ninjutsu_scenario(); - // Add a blocker assignment + // CR 509.1h: a declared block sets the attacker's `blocked` flag (this is + // what `place_blocking` / blocker declaration does in production). Ninjutsu + // reads that flag, so mark the attacker blocked and record the blocker. let blocker_id = create_object( &mut state, CardId(3), @@ -2290,18 +2293,67 @@ mod tests { "Wall".to_string(), crate::types::zones::Zone::Battlefield, ); - state - .combat - .as_mut() - .unwrap() - .blocker_assignments - .insert(attacker_id, vec![blocker_id]); + { + let combat = state.combat.as_mut().unwrap(); + combat + .blocker_assignments + .insert(attacker_id, vec![blocker_id]); + combat + .attackers + .iter_mut() + .find(|a| a.object_id == attacker_id) + .unwrap() + .blocked = true; + } let mut events = Vec::new(); let result = activate_ninjutsu(&mut state, PlayerId(0), ninja_id, attacker_id, &mut events); assert!(result.is_err(), "Should fail when attacker is blocked"); } + #[test] + fn ninjutsu_fails_if_attacker_blocked_by_effect_without_assignments() { + // CR 702.49a + CR 509.1h: ninjutsu returns an UNBLOCKED attacker. An + // attacker made blocked purely by an effect (blocked flag set, NO + // blocker_assignments) is ineligible. This fails if keywords.rs reverts to + // the old `blocker_assignments`-non-empty check. + let (mut state, attacker_id, ninja_id) = setup_ninjutsu_scenario(); + crate::game::combat::mark_attacker_blocked(&mut state, attacker_id); + assert!( + state + .combat + .as_ref() + .unwrap() + .blocker_assignments + .is_empty(), + "reach-guard: an effect-block has no blocker assignments" + ); + + let mut events = Vec::new(); + let result = activate_ninjutsu(&mut state, PlayerId(0), ninja_id, attacker_id, &mut events); + assert!( + result.is_err(), + "ninjutsu must reject an effect-blocked attacker (CR 702.49a + 509.1h)" + ); + + // Reach-guard: the SAME scenario with an unblocked attacker succeeds, + // proving the rejection above is caused by the blocked flag, not an + // unrelated failure. + let (mut ok_state, ok_attacker, ok_ninja) = setup_ninjutsu_scenario(); + let mut ok_events = Vec::new(); + let ok = activate_ninjutsu( + &mut ok_state, + PlayerId(0), + ok_ninja, + ok_attacker, + &mut ok_events, + ); + assert!( + ok.is_ok(), + "unblocked attacker must be ninjutsu-eligible: {ok:?}" + ); + } + #[test] fn ninjutsu_fails_without_combat() { let (mut state, attacker_id, ninja_id) = setup_ninjutsu_scenario(); diff --git a/crates/engine/src/game/log.rs b/crates/engine/src/game/log.rs index f2dbc63698..cb89131e99 100644 --- a/crates/engine/src/game/log.rs +++ b/crates/engine/src/game/log.rs @@ -113,6 +113,7 @@ fn categorize(event: &GameEvent) -> LogCategory { GameEvent::AttackersDeclared { .. } | GameEvent::BlockersDeclared { .. } + | GameEvent::AttackerBecameBlockedByEffect { .. } | GameEvent::CreatureExerted { .. } | GameEvent::CreatureEnlisted { .. } | GameEvent::CombatDamageDealtToPlayer { .. } => LogCategory::Combat, @@ -578,6 +579,11 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec { segs } + // CR 509.1h: an effect made an attacker become blocked (no blockers). + GameEvent::AttackerBecameBlockedByEffect { attacker } => { + vec![card_seg(state, *attacker), text(" becomes blocked")] + } + GameEvent::CombatDamageDealtToPlayer { player_id, source_amounts, diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index 3bad910907..de42b8a690 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -1110,6 +1110,7 @@ fn walk_effect(effect: &Effect, out: &mut Vec) { | Effect::BecomePrepared { .. } | Effect::BecomeUnprepared { .. } | Effect::BecomeSaddled { .. } + | Effect::BecomeBlocked { .. } | Effect::SetClassLevel { .. } | Effect::AddRestriction { .. } | Effect::ReduceNextSpellCost { .. } diff --git a/crates/engine/src/game/public_state.rs b/crates/engine/src/game/public_state.rs index 90b663ed12..9c2f838e3b 100644 --- a/crates/engine/src/game/public_state.rs +++ b/crates/engine/src/game/public_state.rs @@ -415,6 +415,7 @@ pub fn mark_public_state_from_events(state: &mut GameState, events: &[GameEvent] | GameEvent::Unattached { .. } | GameEvent::AttackersDeclared { .. } | GameEvent::BlockersDeclared { .. } + | GameEvent::AttackerBecameBlockedByEffect { .. } | GameEvent::CombatTaxPaid { .. } | GameEvent::CombatTaxDeclined { .. } | GameEvent::BecomesTarget { .. } diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index c8a95e593b..585d15d4ed 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -2024,11 +2024,17 @@ pub(crate) fn is_source_blocked( state: &crate::types::game_state::GameState, source_id: ObjectId, ) -> bool { - state - .combat - .as_ref() - .and_then(|combat| combat.blocker_assignments.get(&source_id)) - .is_some_and(|blockers| !blockers.is_empty()) + // CR 509.1h: "blocked" is the attacker's `blocked` flag, not the presence of + // blocker assignments — a creature made blocked by an effect (no blockers) is + // still blocked, and a creature stays blocked even if all its blockers are + // removed. Mirrors `unblocked_attackers` / `FilterProp::Unblocked`, which read + // the same flag. + state.combat.as_ref().is_some_and(|combat| { + combat + .attackers + .iter() + .any(|a| a.object_id == source_id && a.blocked) + }) } /// CR 508.1d + CR 508.1h: Whether a declared `AttackTarget` falls within a @@ -3805,4 +3811,64 @@ mod tests { assert!(!is_sorcery_speed_window(&state, opponent)); } } + + #[test] + fn is_source_blocked_reads_blocked_flag_not_assignments() { + // CR 509.1h: `is_source_blocked` must read the attacker's `blocked` flag, + // so a creature made blocked by an effect (with NO blocker_assignments) + // reads true. This assertion fails if the body is reverted to the old + // `blocker_assignments`-non-empty check. + use crate::game::combat::{ + mark_attacker_blocked, place_blocking, AttackTarget, AttackerInfo, CombatState, + }; + let mut state = crate::types::game_state::GameState::new_two_player(42); + + let effect_blocked = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Effect Attacker".to_string(), + Zone::Battlefield, + ); + let normally_blocked = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Declared Attacker".to_string(), + Zone::Battlefield, + ); + let blocker = create_object( + &mut state, + CardId(3), + PlayerId(1), + "Blocker".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&blocker).unwrap().controller = PlayerId(1); + + let mut combat = CombatState::default(); + combat.attackers.push(AttackerInfo::new( + effect_blocked, + AttackTarget::Player(PlayerId(1)), + PlayerId(1), + )); + combat.attackers.push(AttackerInfo::new( + normally_blocked, + AttackTarget::Player(PlayerId(1)), + PlayerId(1), + )); + state.combat = Some(combat); + + // Effect-block: no blocker assigned, only the flag set. + assert!(mark_attacker_blocked(&mut state, effect_blocked)); + assert!( + is_source_blocked(&state, effect_blocked), + "an effect-blocked attacker (no assignments) must read as blocked (CR 509.1h)" + ); + + // Reach-guard: a normally place_blocking-blocked attacker also reads true, + // proving the assertion above is not vacuous. + assert!(place_blocking(&mut state, blocker, normally_blocked)); + assert!(is_source_blocked(&state, normally_blocked)); + } } diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs index 3b1ee21f2a..1406784894 100644 --- a/crates/engine/src/game/targeting.rs +++ b/crates/engine/src/game/targeting.rs @@ -1144,6 +1144,11 @@ fn blocked_attacker_from_event( event: &crate::types::events::GameEvent, source_id: ObjectId, ) -> Option { + // CR 509.3c: an effect-driven "becomes blocked" carries only the attacker + // (the blocked creature); "that creature" resolves to that attacker. + if let crate::types::events::GameEvent::AttackerBecameBlockedByEffect { attacker } = event { + return Some(*attacker); + } let crate::types::events::GameEvent::BlockersDeclared { assignments } = event else { return None; }; @@ -1310,6 +1315,9 @@ pub(crate) fn extract_source_from_event( let first = blockers.next()?; blockers.all(|blocker| blocker == first).then_some(first) } + // CR 509.3c: an effect-driven "becomes blocked" trigger's source is the + // attacker that became blocked. + GameEvent::AttackerBecameBlockedByEffect { attacker } => Some(*attacker), _ => None, } } diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index 37673bc735..dbb1916e38 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -593,6 +593,9 @@ pub(crate) fn keys_from_event(event: &GameEvent, state: &GameState) -> Keys { GameEvent::Unattached { .. } => push(TriggerEventKey::AttachmentChanged), GameEvent::AttackersDeclared { .. } => push(TriggerEventKey::Attacks), GameEvent::BlockersDeclared { .. } => push(TriggerEventKey::Blocks), + // CR 509.3c: an effect-driven "becomes blocked" is a Blocks-key event so + // "whenever ~ becomes blocked" triggers are indexed for it. + GameEvent::AttackerBecameBlockedByEffect { .. } => push(TriggerEventKey::Blocks), GameEvent::CombatTaxPaid { .. } | GameEvent::CombatTaxDeclined { .. } => {} GameEvent::BecomesTarget { .. } => push(TriggerEventKey::BecomesTarget), GameEvent::VehicleCrewed { .. } @@ -894,6 +897,10 @@ fn keys_from_effect_kind(kind: EffectKind, push: &mut impl FnMut(TriggerEventKey | EffectKind::SetDayNight | EffectKind::GiveControl | EffectKind::RemoveFromCombat + // CR 509.3c: the "becomes blocked" trigger from an effect-block is keyed + // off the `AttackerBecameBlockedByEffect` GameEvent (see the event→key + // map above), not off `EffectResolved`, so this kind emits no key here. + | EffectKind::BecomeBlocked | EffectKind::Conjure | EffectKind::Intensify | EffectKind::ApplyPerpetual diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index 56bc0d2c34..9b1c3772d5 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -900,6 +900,9 @@ fn count_matching_trigger_event_subjects( | GameEvent::EffectResolved { .. } | GameEvent::Unattached { .. } | GameEvent::BlockersDeclared { .. } + // Mirrors BlockersDeclared: the "becomes blocked" trigger uses the + // dedicated matcher, not this generic per-object count helper. + | GameEvent::AttackerBecameBlockedByEffect { .. } | GameEvent::CombatTaxPaid { .. } | GameEvent::CombatTaxDeclined { .. } | GameEvent::VehicleCrewed { .. } @@ -3265,6 +3268,25 @@ pub(super) fn matching_becomes_blocked_events( source_id: ObjectId, state: &GameState, ) -> Vec { + if let GameEvent::AttackerBecameBlockedByEffect { attacker } = event { + // CR 509.3d: an effect-driven block is NOT "blocked by a creature" — the + // "becomes blocked by a creature" form (which carries a `valid_target` + // blocker filter) must NOT fire. Only the bare "becomes blocked" form + // (CR 509.3c) fires, and only for the matching attacker. + if trigger.valid_target.is_some() { + return Vec::new(); + } + let attacker_matches = if trigger.valid_card.is_some() { + valid_card_matches(trigger, state, *attacker, source_id) + } else { + *attacker == source_id + }; + return if attacker_matches { + vec![event.clone()] + } else { + Vec::new() + }; + } if let GameEvent::BlockersDeclared { assignments } = event { // CR 509.3d: the "becomes blocked by a creature [with quality]" form // (carries a `valid_target` blocker filter) triggers once for each @@ -4577,6 +4599,60 @@ mod tests { TriggerDefinition::new(mode) } + #[test] + fn effect_block_fires_becomes_blocked_but_not_block_side_matchers() { + // CR 509.3c: a bare "whenever ~ becomes blocked" trigger (valid_target = + // None) fires from an effect-block for the matching attacker. + // CR 509.3d: the blocker-side matchers (`matching_block_events`, + // `match_blockers_declared`) must ignore the effect-block event entirely — + // they concrete-match `BlockersDeclared`. These assertions fail if a + // synthetic `BlockersDeclared` were reintroduced for effect-blocks. + let mut state = setup(); + let attacker = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Effect-Blocked Attacker".to_string(), + Zone::Battlefield, + ); + let event = GameEvent::AttackerBecameBlockedByEffect { attacker }; + + // Positive (CR 509.3c): the bare becomes-blocked matcher fires, source == attacker. + let bare = make_trigger(TriggerMode::BecomesBlocked); + let fired = matching_becomes_blocked_events(&event, &bare, attacker, &state); + assert_eq!(fired.len(), 1, "bare becomes-blocked fires on effect-block"); + + // CR 509.3d: the "by a creature" form (valid_target set) must NOT fire. + let mut by_creature = make_trigger(TriggerMode::BecomesBlocked); + by_creature.valid_target = Some(TargetFilter::Any); + assert!( + matching_becomes_blocked_events(&event, &by_creature, attacker, &state).is_empty(), + "becomes-blocked-BY-A-CREATURE must not fire on an effect-block (CR 509.3d)" + ); + + // CR 509.3d: blocker-side matchers ignore the effect-block event. + let blocks = make_trigger(TriggerMode::Blocks); + assert!( + matching_block_events(&event, &blocks, attacker, &state).is_empty(), + "block-side matcher must ignore the effect-block event (CR 509.3d)" + ); + assert!( + !match_blockers_declared(&event, &blocks, attacker, &state), + "match_blockers_declared must ignore the effect-block event (CR 509.3d)" + ); + + // Reach-guard: match_blockers_declared DOES fire on a real BlockersDeclared, + // proving the negative above is not vacuous. + assert!(match_blockers_declared( + &GameEvent::BlockersDeclared { + assignments: vec![(attacker, attacker)], + }, + &blocks, + attacker, + &state, + )); + } + /// CR 702.143c: an effect-driven `BecameForetold` is NOT the foretell /// special action, so a "whenever you foretell a card" trigger /// (`match_foretell`) must not fire on it — only the `Foretold` special-action diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index e6ac31f517..0cffee78ac 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -5077,6 +5077,7 @@ pub(super) fn clause_is_dig_lookback_transparent(effect: &Effect) -> bool { | Effect::BecomePrepared { .. } | Effect::BecomeUnprepared { .. } | Effect::BecomeSaddled { .. } + | Effect::BecomeBlocked { .. } | Effect::SetClassLevel { .. } | Effect::CreateDelayedTrigger { .. } | Effect::AddTargetReplacement { .. } diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index c843132e88..151365fbf6 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -3425,6 +3425,22 @@ fn build_become_clause( return Some(super::parsed_clause(Effect::BecomeSaddled { target })); } + // CR 509.1h: "becomes blocked" makes the target attacking creature a blocked + // creature with no blockers assigned (Dazzling Beauty: "Target unblocked + // attacking creature becomes blocked."). Mirrors the saddled idiom: a real + // "Target ... creature" subject carries its `Typed` filter as the target slot; + // an anaphoric subject falls back to `affected`. + if all_consuming(tag::<_, _, OracleError<'_>>("blocked")) + .parse(become_lower.as_str()) + .is_ok() + { + let target = application + .target + .clone() + .unwrap_or_else(|| application.affected.clone()); + return Some(super::parsed_clause(Effect::BecomeBlocked { target })); + } + // CR 707.2 / CR 613.1a: "become a copy of [target]" — copy copiable characteristics. // Must intercept before parse_animation_spec which rejects "copy of" patterns. // @@ -7951,4 +7967,52 @@ mod tests { let mut ctx = ParseContext::default(); assert!(try_parse_copula_goaded_clause("it's goaded and draws a card", &mut ctx).is_none()); } + + // CR 509.1h: "Target unblocked attacking creature becomes blocked." parses to + // `Effect::BecomeBlocked` whose target is a Typed(creature) filter carrying + // both FilterProp::Unblocked and FilterProp::Attacking. SHAPE test — runtime + // semantics are covered by the cast-pipeline tests in + // tests/dazzling_beauty_become_blocked.rs. + #[test] + fn become_blocked_parses_with_unblocked_attacking_target() { + use crate::types::ability::{FilterProp, TargetFilter, TypeFilter, TypedFilter}; + + let effect = + super::super::parse_effect("Target unblocked attacking creature becomes blocked."); + let Effect::BecomeBlocked { target } = &effect else { + panic!("expected Effect::BecomeBlocked, got {effect:?}"); + }; + let TargetFilter::Typed(TypedFilter { + type_filters, + properties, + .. + }) = target + else { + panic!("expected a Typed creature target, got {target:?}"); + }; + assert!( + type_filters + .iter() + .any(|t| matches!(t, TypeFilter::Creature)), + "target must be a creature filter, got {type_filters:?}" + ); + assert!( + properties + .iter() + .any(|p| matches!(p, FilterProp::Unblocked)), + "target must carry FilterProp::Unblocked (CR 509.1h), got {properties:?}" + ); + assert!( + properties + .iter() + .any(|p| matches!(p, FilterProp::Attacking { .. })), + "target must carry FilterProp::Attacking, got {properties:?}" + ); + // Reach-guard against a vacuous parse: the effect is the concrete + // BecomeBlocked variant, not Unimplemented. + assert!( + !matches!(effect, Effect::Unimplemented { .. }), + "must not fall through to Unimplemented" + ); + } } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 3a07843db7..b000675b71 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -11307,6 +11307,15 @@ pub enum Effect { #[serde(default = "default_target_filter_any")] target: TargetFilter, }, + /// CR 509.1h: An effect can make an attacking creature become blocked; it + /// remains blocked even if all creatures blocking it are removed from combat. + /// CR 510.1c: a blocked creature with no creatures blocking it assigns no + /// combat damage. General primitive covering the ~21-card "target ... becomes + /// blocked" class (e.g. Dazzling Beauty). + BecomeBlocked { + #[serde(default = "default_target_filter_any")] + target: TargetFilter, + }, /// Digital-only keyword action (no CR entry): Conjure creates a card from outside /// the game and places it into a specified zone. Unlike tokens, conjured cards are /// "real" cards with full characteristics (mana value, types, abilities, etc.). @@ -12384,6 +12393,7 @@ impl Effect { | Effect::SetLifeTotal { target, .. } | Effect::GiveControl { target, .. } | Effect::RemoveFromCombat { target, .. } + | Effect::BecomeBlocked { target, .. } | Effect::PutSticker { target, .. } | Effect::ApplySticker { target, .. } | Effect::ProliferateTarget { target, .. } @@ -12917,6 +12927,7 @@ impl Effect { | Effect::Double { .. } | Effect::GiveControl { .. } | Effect::RemoveFromCombat { .. } + | Effect::BecomeBlocked { .. } | Effect::ChangeTargets { .. } | Effect::AddRestriction { .. } | Effect::AddTargetReplacement { .. } @@ -13155,6 +13166,7 @@ impl Effect { | Effect::Double { .. } | Effect::GiveControl { .. } | Effect::RemoveFromCombat { .. } + | Effect::BecomeBlocked { .. } | Effect::ChangeTargets { .. } | Effect::AddRestriction { .. } | Effect::AddTargetReplacement { .. } @@ -13458,6 +13470,7 @@ pub fn effect_variant_name(effect: &Effect) -> &str { Effect::SetDayNight { .. } => "SetDayNight", Effect::GiveControl { .. } => "GiveControl", Effect::RemoveFromCombat { .. } => "RemoveFromCombat", + Effect::BecomeBlocked { .. } => "BecomeBlocked", Effect::Conjure { .. } => "Conjure", Effect::Intensify { .. } => "Intensify", Effect::ApplyPerpetual { .. } => "ApplyPerpetual", @@ -13688,6 +13701,7 @@ pub enum EffectKind { SetDayNight, GiveControl, RemoveFromCombat, + BecomeBlocked, Conjure, Intensify, ApplyPerpetual, @@ -13947,6 +13961,7 @@ impl From<&Effect> for EffectKind { Effect::SetDayNight { .. } => EffectKind::SetDayNight, Effect::GiveControl { .. } => EffectKind::GiveControl, Effect::RemoveFromCombat { .. } => EffectKind::RemoveFromCombat, + Effect::BecomeBlocked { .. } => EffectKind::BecomeBlocked, Effect::Conjure { .. } => EffectKind::Conjure, Effect::Intensify { .. } => EffectKind::Intensify, Effect::ApplyPerpetual { .. } => EffectKind::ApplyPerpetual, diff --git a/crates/engine/src/types/events.rs b/crates/engine/src/types/events.rs index 3beab5031a..336c1c82ee 100644 --- a/crates/engine/src/types/events.rs +++ b/crates/engine/src/types/events.rs @@ -528,6 +528,15 @@ pub enum GameEvent { BlockersDeclared { assignments: Vec<(ObjectId, ObjectId)>, }, + /// CR 509.3c: An effect made an attacking creature become blocked, and it was + /// an unblocked creature at that time — the precondition for "becomes blocked" + /// triggers to fire from an effect-block. + /// CR 509.3d: A "becomes blocked BY A CREATURE" trigger, and any blocker-side + /// "whenever ~ blocks" trigger, must NOT fire from an effect-block — this event + /// is distinct from BlockersDeclared precisely so those matchers ignore it. + AttackerBecameBlockedByEffect { + attacker: ObjectId, + }, /// CR 508.1h + CR 509.1d: The aggregate combat tax was paid; the declaration /// proceeds with every declared creature intact. CombatTaxPaid { diff --git a/crates/engine/tests/dazzling_beauty_become_blocked.rs b/crates/engine/tests/dazzling_beauty_become_blocked.rs new file mode 100644 index 0000000000..a1ca8e8d79 --- /dev/null +++ b/crates/engine/tests/dazzling_beauty_become_blocked.rs @@ -0,0 +1,227 @@ +//! Cast-pipeline + combat tests for the general `Effect::BecomeBlocked` primitive +//! (Dazzling Beauty class). Every test drives the real pipeline: casts through +//! `GameRunner::cast(..).resolve()` in the declare-blockers window and runs real +//! combat damage / triggers through the scenario runner. +//! +//! Verbatim Oracle text (Dazzling Beauty): +//! "Cast this spell only during the declare blockers step.\n +//! Target unblocked attacking creature becomes blocked. (This spell works on +//! creatures that can't be blocked.)\n +//! Draw a card at the beginning of the next turn's upkeep." + +use engine::game::combat::{unblocked_attackers, AttackTarget}; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; + +/// Drive combat from the declare-attackers priority window into the +/// declare-blockers step, then hand priority to `caster` so a defending player +/// can cast during that step (CR 509). Panics if the declare-blockers step is +/// not reached. +fn advance_to_declare_blockers_and_give_priority(runner: &mut GameRunner, caster: PlayerId) { + for _ in 0..20 { + let wf = runner.state().waiting_for.clone(); + if runner.state().phase == Phase::DeclareBlockers { + let state = runner.state_mut(); + state.priority_player = caster; + state.waiting_for = WaitingFor::Priority { player: caster }; + return; + } + let acted = match wf { + WaitingFor::Priority { .. } => runner.act(GameAction::PassPriority), + WaitingFor::DeclareBlockers { .. } => runner.act(GameAction::DeclareBlockers { + assignments: vec![], + }), + other => panic!("unexpected waiting state before declare-blockers: {other:?}"), + }; + acted.expect("combat advance action"); + } + panic!("did not reach the declare-blockers step"); +} + +const DAZZLING_BEAUTY: &str = "Cast this spell only during the declare blockers step.\nTarget unblocked attacking creature becomes blocked. (This spell works on creatures that can't be blocked.)\nDraw a card at the beginning of the next turn's upkeep."; + +/// Just the becomes-blocked line, for tests that only exercise the combat effect. +const BECOMES_BLOCKED_ONLY: &str = "Cast this spell only during the declare blockers step.\nTarget unblocked attacking creature becomes blocked."; + +/// Build a scenario with P0 (the default active player) attacking P1 with a +/// single creature, advanced to the declare-blockers step. P1 is the defending +/// caster of Dazzling Beauty. Returns (runner, attacker_id, spell_id). +fn attack_setup(attacker_power: i32, spell_oracle: &str) -> (GameRunner, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let attacker = scenario + .add_creature(P0, "Charging Ox", attacker_power, attacker_power) + .id(); + // P1 (defender) holds Dazzling Beauty; scenario spells are NoCost. + let spell = scenario + .add_spell_to_hand_from_oracle(P1, "Dazzling Beauty", true, spell_oracle) + .id(); + let mut runner = scenario.build(); + runner.advance_to_combat(); + runner + .declare_attackers(&[(attacker, AttackTarget::Player(P1))]) + .expect("declare attackers"); + advance_to_declare_blockers_and_give_priority(&mut runner, P1); + (runner, attacker, spell) +} + +#[test] +fn becomes_blocked_deals_no_damage() { + // CR 510.1c: a creature blocked by an effect (no blockers) assigns no combat + // damage. Revert-failing assertion: P0 life delta is 0. + let (mut runner, attacker, spell) = attack_setup(3, BECOMES_BLOCKED_ONLY); + runner.cast(spell).target_objects(&[attacker]).resolve(); + // The attacker is now blocked with no blockers assigned. + assert!(!unblocked_attackers(runner.state()).contains(&attacker)); + let outcome = runner.combat_damage(); + outcome.assert_life_delta(P1, 0); +} + +#[test] +fn control_without_spell_takes_full_damage() { + // Reach-guard for `becomes_blocked_deals_no_damage`: the SAME setup WITHOUT + // casting the spell deals full damage, proving the 0-delta above is caused by + // the effect, not by an unrelated short-circuit. + let (mut runner, attacker, _spell) = attack_setup(3, BECOMES_BLOCKED_ONLY); + assert!(unblocked_attackers(runner.state()).contains(&attacker)); + let outcome = runner.combat_damage(); + outcome.assert_life_delta(P1, -3); +} + +#[test] +fn becomes_blocked_trigger_fires_and_block_side_trigger_does_not() { + // CR 509.3c: a "whenever ~ becomes blocked" trigger on the attacker fires from + // an effect-block (positive: proves the event fires at all). + // CR 509.3d: a blocker-side "whenever ~ blocks" trigger must NOT fire from an + // effect-block (negative, paired so the negative is not vacuous). + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // P0 (active) attacks with a creature carrying a "becomes blocked" trigger. + let attacker = scenario + .add_creature_from_oracle( + P0, + "Watchful Ox", + 2, + 2, + "Whenever this creature becomes blocked, you gain 3 life.", + ) + .id(); + // A P1 creature with a "whenever ~ blocks" trigger that must stay silent. + let _watcher = scenario + .add_creature_from_oracle( + P1, + "Eager Blocker", + 1, + 1, + "Whenever this creature blocks, you gain 5 life.", + ) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P1, "Dazzling Beauty", true, BECOMES_BLOCKED_ONLY) + .id(); + let mut runner = scenario.build(); + runner.advance_to_combat(); + runner + .declare_attackers(&[(attacker, AttackTarget::Player(P1))]) + .expect("declare attackers"); + advance_to_declare_blockers_and_give_priority(&mut runner, P1); + + let p0_life_before = runner.life(P0); + let p1_life_before = runner.life(P1); + runner.cast(spell).target_objects(&[attacker]).resolve(); + runner.advance_until_stack_empty(); + + // CR 509.3c: attacker's "becomes blocked" trigger fired → P0 gained 3 life. + assert_eq!( + runner.life(P0) - p0_life_before, + 3, + "becomes-blocked trigger should fire from an effect-block (CR 509.3c)" + ); + // CR 509.3d: the blocker-side "whenever ~ blocks" trigger must not fire (no + // creature was actually declared as a blocker) → P1 did not gain 5 life. + assert_eq!( + runner.life(P1) - p1_life_before, + 0, + "block-side trigger must not fire on an effect-block (CR 509.3d)" + ); +} + +#[test] +fn works_on_unblockable() { + // The effect makes an attacker blocked even though it "can't be blocked" — + // proving it is NOT routed through place_blocking (which would reject an + // unblockable). CR 510.1c: still 0 combat damage. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let attacker = scenario + .add_creature_from_oracle(P0, "Slippery Ox", 4, 4, "This creature can't be blocked.") + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P1, "Dazzling Beauty", true, BECOMES_BLOCKED_ONLY) + .id(); + let mut runner = scenario.build(); + runner.advance_to_combat(); + runner + .declare_attackers(&[(attacker, AttackTarget::Player(P1))]) + .expect("declare attackers"); + advance_to_declare_blockers_and_give_priority(&mut runner, P1); + + runner.cast(spell).target_objects(&[attacker]).resolve(); + assert!(!unblocked_attackers(runner.state()).contains(&attacker)); + let outcome = runner.combat_damage(); + outcome.assert_life_delta(P1, 0); +} + +#[test] +fn full_chain_parses_become_blocked_then_delayed_draw() { + // SHAPE: the full Dazzling Beauty text composes the new BecomeBlocked head + // effect with the pre-existing delayed-draw sub-ability, and the casting + // restriction, with NO Effect::Unimplemented anywhere. The delayed draw and + // casting restriction are pre-existing behavior (out of scope for the + // BecomeBlocked primitive); this test guards that adding BecomeBlocked did not + // disturb the chain composition. Runtime behavior of BecomeBlocked itself is + // covered by the cast-pipeline tests above. + use engine::parser::oracle::parse_oracle_text; + use engine::types::ability::Effect; + + let parsed = parse_oracle_text( + DAZZLING_BEAUTY, + "Dazzling Beauty", + &[], + &["Instant".to_string()], + &[], + ); + assert_eq!(parsed.abilities.len(), 1, "one spell ability"); + let ability = &parsed.abilities[0]; + assert!( + matches!(*ability.effect, Effect::BecomeBlocked { .. }), + "head effect must be BecomeBlocked, got {:?}", + ability.effect + ); + // The delayed draw composes as the sub-ability. + let sub = ability + .sub_ability + .as_ref() + .expect("delayed-draw sub-ability must compose after BecomeBlocked"); + assert!( + matches!(*sub.effect, Effect::CreateDelayedTrigger { .. }), + "sub effect must be the delayed draw, got {:?}", + sub.effect + ); + // Reach-guard against a vacuous no-Unimplemented pass: assert the casting + // restriction parsed too, proving the whole card was consumed. + assert!( + !parsed.casting_restrictions.is_empty(), + "the declare-blockers casting restriction must parse" + ); + // No Unimplemented anywhere in the parsed abilities. + let debug = format!("{parsed:?}"); + assert!( + !debug.contains("Unimplemented"), + "no Effect::Unimplemented in the parsed Dazzling Beauty chain" + ); +} diff --git a/crates/phase-ai/src/policies/effect_classify.rs b/crates/phase-ai/src/policies/effect_classify.rs index 5e50dc6aca..2a7754903c 100644 --- a/crates/phase-ai/src/policies/effect_classify.rs +++ b/crates/phase-ai/src/policies/effect_classify.rs @@ -329,6 +329,7 @@ pub(crate) fn effect_polarity(effect: &Effect) -> EffectPolarity { | Effect::RegisterBending { .. } | Effect::RememberCard { .. } | Effect::RemoveFromCombat { .. } + | Effect::BecomeBlocked { .. } | Effect::Renown { .. } | Effect::ReturnAsAura { .. } | Effect::Reveal { .. } diff --git a/crates/phase-ai/src/policies/redundancy_avoidance.rs b/crates/phase-ai/src/policies/redundancy_avoidance.rs index 961624f974..bd96563097 100644 --- a/crates/phase-ai/src/policies/redundancy_avoidance.rs +++ b/crates/phase-ai/src/policies/redundancy_avoidance.rs @@ -561,6 +561,9 @@ fn redundancy_delta( // CR 702.171b: a permanent cannot become saddled if already saddled; no // static redundancy signal — leave to the resolver. | Effect::BecomeSaddled { .. } + // CR 509.1h: "becomes blocked" has no static redundancy signal (the + // target's blocked state is combat-scoped) — leave it to the resolver. + | Effect::BecomeBlocked { .. } // CR 702.95c-d: PairWith mutates the source/target pair relationship; // redundancy depends on trigger timing and revalidation, so this policy // leaves it to the resolver.