Skip to content
Merged
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
1 change: 1 addition & 0 deletions crates/engine/src/analysis/ability_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,7 @@ fn effect_projection(effect: &Effect) -> Projection {
| Effect::SetDayNight { .. }
| Effect::GiveControl { .. }
| Effect::RemoveFromCombat { .. }
| Effect::BecomeBlocked { .. }
| Effect::ApplyPerpetual { .. }
| Effect::Intensify { .. }
| Effect::DraftFromSpellbook { .. }
Expand Down
10 changes: 10 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, .. }
Expand Down Expand Up @@ -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));
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 { .. }
Expand Down
19 changes: 19 additions & 0 deletions crates/engine/src/game/combat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
Expand Down
211 changes: 211 additions & 0 deletions crates/engine/src/game/effects/become_blocked.rs
Original file line number Diff line number Diff line change
@@ -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<ObjectId> {
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<GameEvent>,
) -> 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 { .. })));
}
}
4 changes: 4 additions & 0 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 { .. } => {
Expand Down
Loading
Loading