diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 3bd2709c44..7fe2cc4c32 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1316,6 +1316,17 @@ impl<'a> CardBuilder<'a> { self } + /// Add the Snow supertype (CR 205.4a: supertypes are printed before card types; + /// CR 205.4g: any permanent with the supertype "snow" is a snow permanent). + pub fn as_snow(&mut self) -> &mut Self { + let obj = self.obj(); + if !obj.card_types.supertypes.contains(&Supertype::Snow) { + obj.card_types.supertypes.push(Supertype::Snow); + } + self.sync_base_card_types(); + self + } + // --- Special modifiers --- /// CR 903.3: Mark this object as its owner's commander IN PLACE, without @@ -1389,6 +1400,14 @@ impl<'a> CardBuilder<'a> { self } + /// Set the color and base color of this card (CR 105.1). + pub fn with_color(&mut self, colors: Vec) -> &mut Self { + let obj = self.obj(); + obj.color = colors.clone(); + obj.base_color = colors; + self + } + /// Add +1/+1 counters to this creature. pub fn with_plus_counters(&mut self, count: u32) -> &mut Self { let counter = crate::types::counter::CounterType::Plus1Plus1; diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 30b2c5b498..c95cdada17 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -8538,18 +8538,43 @@ fn finish_damage_source_subject(subject: &str) -> Option { // by finding the last "if " clause, which contains the actual replacement condition. // Use split_once_on to extract the last "if " clause (for ability word prefixes). // rsplit equivalent: take everything after the last "if " occurrence. - let subject = { + let (subject, had_if) = { + let mut had = false; let mut last = subject; let mut remaining = subject; while let Ok((_, (_, after))) = nom_primitives::split_once_on(remaining, "if ") { + had = true; last = after; remaining = after; } - last.trim() + (last.trim(), had) }; - // Self-reference: "~" after stripping "if" - if subject == "~" { + // CR 615.1a: When the source subject is embedded in an active-voice + // damage-quantifier clause ("prevent all [combat] damage [that] ", "double all damage + // [that] ", "the next N damage that "), extract the span following the + // head noun "damage" and strip any optional relative "that " marker to reach the source subject. + let subject = if !had_if { + let mut last = subject; + let mut remaining = subject; + while let Ok((_, (_, after))) = nom_primitives::split_once_on(remaining, "damage ") { + last = after; + remaining = after; + } + opt(tag::<_, _, OracleError<'_>>("that ")) + .parse(last.trim()) + .map_or(last.trim(), |(rest, _)| rest) + .trim() + } else { + subject + }; + + // Self-reference: "~", "it", "this creature", "this permanent" after stripping + if subject == "~" + || subject == "it" + || subject == "this creature" + || subject == "this permanent" + { return Some(TargetFilter::SelfRef); } @@ -8848,6 +8873,12 @@ fn parse_damage_target_filter(norm_lower: &str) -> Option { if let Ok((_, filter)) = parse_damage_target_phrase(remaining) { // Guard: opponent-only and player-only exclude "permanent" from the full text match filter { + // CR 615.1a: "permanent or player" = any -> None on the definition (unrestricted). + DamageTargetFilter::PlayerOrPermanentsControlledBy { + player: DamageTargetPlayerScope::Any, + permanent_type: None, + .. + } => return None, DamageTargetFilter::Player { .. } if nom_primitives::scan_contains(norm_lower, "permanent") => { @@ -8904,6 +8935,16 @@ fn damage_target_source_chosen_player_or_permanents() -> DamageTargetFilter { } } +/// CR 109.1 + CR 615.1a: "to a permanent or player" — the universal +/// damage recipient domain, covering any player or any permanent. +fn damage_target_any_permanent_or_player() -> DamageTargetFilter { + DamageTargetFilter::PlayerOrPermanentsControlledBy { + player: DamageTargetPlayerScope::Any, + permanent_type: None, + source_scope: SourceExclusion::Include, + } +} + /// Nom combinator for damage target phrases. Most specific tags first. fn parse_damage_target_phrase( input: &str, @@ -8964,9 +9005,21 @@ fn parse_damage_target_phrase( // than collapsed to `Any`. Mirrors the durable path's use of // `damage_target_controller()` for "would be dealt to you". value(damage_target_controller(), tag("to you")), + // CR 109.1 + CR 615.1a: "to a permanent or player" — the + // universal recipient domain. Ordered BEFORE `damage_target_any_player` + // so the longer "to a player or permanent" form is not shadowed. + value( + damage_target_any_permanent_or_player(), + alt(( + tag("to a permanent or player"), + tag("to that permanent or player"), + tag("to a player or permanent"), + tag("to that player or permanent"), + )), + ), value( damage_target_any_player(), - alt((tag("to a player"), tag("to that player"))), + alt((tag("to a player"), tag("to that player"), tag("to players"))), ), )) .parse(input) @@ -12545,16 +12598,34 @@ fn parse_damage_prevention_replacement( // whether the recipient is an event-determined OBJECT (vs. the shield // controller or a spell target slot) — that signal gates the follow-up // object/owner-anaphor rewrite in step 5 below. + let recipient_scope = parse_damage_recipient_scope(working_lower); + let recipient_scope_parsed = recipient_scope.is_some(); let (damage_target_filter, recipient_from_event): (Option, bool) = if let Some(tf @ DamageTargetFilter::PlayerOrPermanentsControlledBy { .. }) = - parse_damage_recipient_scope(working_lower) + recipient_scope { // Keep compound player/permanent recipients ahead of the bare // controller scan: "to you or another permanent you control" is // one recipient domain, not a player-only shield. Its rider's // anaphor refers to the actual damage recipient for every player // scope (controller, opponent, or source-chosen player). - (Some(tf), true) + // + // CR 615.1a: "to a permanent or player" is the universal recipient domain. + // It scopes anaphors to the event recipient (`recipient_from_event = true`), + // but installs no target restriction (`damage_target_filter = None`) so all + // permanents and players remain eligible (Plated Pegasus). + if matches!( + tf, + DamageTargetFilter::PlayerOrPermanentsControlledBy { + player: DamageTargetPlayerScope::Any, + permanent_type: None, + .. + } + ) { + (None, true) + } else { + (Some(tf), true) + } } else if nom_primitives::scan_contains(working_lower, "dealt to you") || nom_primitives::scan_contains(working_lower, "deal to you") { @@ -12567,7 +12638,7 @@ fn parse_damage_prevention_replacement( // (Test of Faith). (Some(DamageTargetFilter::CreatureOnly), false) } else { - // CR 614.1a / CR 615.5: typed event recipient anchored at the + // CR 615.1a / CR 615.5: typed event recipient anchored at the // recipient clause ("would deal [combat] damage to a creature" / // "dealt to an opponent"). Anchoring (not whole-text scanning) // prevents a follow-up rider's recipient-shaped phrase from being @@ -12653,6 +12724,31 @@ fn parse_damage_prevention_replacement( .or_else(|| parse_damage_recipient_valid_card_filter(working_lower)) }; + // CR 615.1a: If the prevention clause carries an explicit damage-recipient + // anchor ("dealt to ", "would deal to ", "would deal damage to ", + // "would deal combat damage to ", "deal damage to ") but neither + // `damage_target_filter`, `valid_card_filter`, nor `parse_damage_recipient_scope` + // parsed it, the recipient qualification is unrecognized or incomplete (e.g. + // Light of Sanction's "to creatures you control by sources you control"). + // Fail closed (return None) rather than falling through to an un-scoped shield + // that would wrongly prevent damage to all targets in the game. + let has_recipient_clause = [ + "dealt to ", + "would deal damage to ", + "would deal combat damage to ", + "would deal to ", + "deal damage to ", + ] + .into_iter() + .any(|prefix| nom_primitives::scan_contains(working_lower, prefix)); + + let recipient_recognized = + damage_target_filter.is_some() || valid_card_filter.is_some() || recipient_scope_parsed; + + if has_recipient_clause && !recipient_recognized { + return None; + } + // --- 4. Extract damage source filter --- let damage_source_filter = parse_damage_source_filter(working_lower); @@ -12835,7 +12931,7 @@ fn parse_damage_prevention_replacement( Some(def) } -/// CR 614.1a: Extract the typed event-recipient filter from a damage-prevention +/// CR 615.1a: Extract the typed event-recipient filter from a damage-prevention /// shield's "dealt to " clause. The clause may close at the end of the /// sentence (`.`, `this turn`, `until end of turn`, or input end) or continue /// into a sibling prevention imperative (`, prevent that damage. ...` — Vigor, @@ -12846,11 +12942,15 @@ fn parse_damage_prevention_replacement( fn parse_damage_recipient_valid_card_filter(working_lower: &str) -> Option { parse_damage_recipient_after_prefix(working_lower, "dealt to ") .or_else(|| parse_damage_recipient_after_prefix(working_lower, "would deal damage to ")) + .or_else(|| { + parse_damage_recipient_after_prefix(working_lower, "would deal combat damage to ") + }) + .or_else(|| parse_damage_recipient_after_prefix(working_lower, "would deal to ")) } /// CR 615.1a / CR 615.5: Extract the typed damage-recipient SCOPE from a /// prevention shield's recipient clause, anchored at the -/// "would deal [combat] damage to "/"dealt to " recipient prefix. Mirrors +/// "would deal [combat] damage to "/"would deal to "/"dealt to " recipient prefix. Mirrors /// `parse_damage_recipient_after_prefix` (which returns the `valid_card` /// `TargetFilter` form) but returns a `DamageTargetFilter` via the shared /// `parse_damage_target_phrase` combinator. @@ -12860,17 +12960,83 @@ fn parse_damage_recipient_valid_card_filter(working_lower: &str) -> Option`. +/// 2. A same-sentence prevention imperative (`", prevent"` / `", you may prevent"`), with +/// or without an intervening duration window: +/// e.g. `, prevent that damage.` or `this turn, prevent that damage.` (Invulnerability), +/// or `, you may prevent...` (Battletide Alchemist). +/// +/// The prevention imperative continuation is matched via `peek` without consuming it, +/// preserving ownership for downstream replacement parsing. Unparsed trailing qualifiers +/// (e.g. Light of Sanction's "...by sources you control") fail closed. +fn parse_damage_recipient_terminator(input: &str) -> OracleResult<'_, ()> { + let (input, _) = multispace0.parse(input)?; + let (input, _) = opt(alt(( + tag::<_, _, OracleError<'_>>("this combat"), + tag::<_, _, OracleError<'_>>("this turn"), + tag::<_, _, OracleError<'_>>("until end of turn"), + ))) + .parse(input)?; + let (input, _) = multispace0.parse(input)?; + + alt(( + value( + (), + all_consuming(terminated( + opt(tag::<_, _, OracleError<'_>>(".")), + multispace0, + )), + ), + value( + (), + peek(alt(( + tag::<_, _, OracleError<'_>>(", prevent"), + tag::<_, _, OracleError<'_>>(", you may prevent"), + ))), + ), + )) + .parse(input) +} + +/// CR 615.1a / CR 615.5: Extract the typed damage-recipient SCOPE from a +/// prevention shield's recipient clause, anchored at the +/// "would deal [combat] damage to "/"would deal to "/"dealt to " recipient prefix. Mirrors +/// `parse_damage_recipient_after_prefix` (which returns the `valid_card` +/// `TargetFilter` form) but returns a `DamageTargetFilter` via the shared +/// `parse_damage_target_phrase` combinator. +/// +/// ANCHORING at the recipient prefix — instead of scanning the whole normalized +/// text with `parse_damage_target_filter` — prevents a follow-up rider that +/// itself contains a recipient-shaped phrase (e.g. "...deal that much damage to +/// a creature you control") from being misbound as the shield's recipient +/// scope. Builds for the class, not the card. +/// +/// CR 615.1a: Uses `parse_damage_recipient_terminator` to verify the clause boundary. fn parse_damage_recipient_scope(working_lower: &str) -> Option { // `parse_damage_target_phrase` consumes the leading "to "; the // anchor prefix therefore stops just before "to ". - ["would deal combat damage ", "would deal damage ", "dealt "] - .into_iter() - .find_map(|prefix| { - nom_primitives::scan_at_word_boundaries(working_lower, |input| { - let (after_prefix, _) = tag::<_, _, OracleError<'_>>(prefix).parse(input)?; - parse_damage_target_phrase(after_prefix) - }) + [ + "would deal combat damage ", + "would deal damage ", + "would deal ", + "dealt ", + ] + .into_iter() + .find_map(|prefix| { + nom_primitives::scan_at_word_boundaries(working_lower, |input| { + let (after_prefix, _) = tag::<_, _, OracleError<'_>>(prefix).parse(input)?; + let (rest, target_filter) = parse_damage_target_phrase(after_prefix)?; + let (rest, _) = parse_damage_recipient_terminator(rest)?; + Ok((rest, target_filter)) }) + }) } fn parse_damage_recipient_after_prefix(working_lower: &str, prefix: &str) -> Option { @@ -12884,43 +13050,8 @@ fn parse_damage_recipient_after_prefix(working_lower: &str, prefix: &str) -> Opt ))); } - let rest = rest.trim_start(); - let fully_consumed = all_consuming(alt(( - value((), eof::<&str, OracleError<'_>>), - value((), tag::<_, _, OracleError<'_>>(".")), - value( - (), - terminated( - tag::<_, _, OracleError<'_>>("this turn"), - opt(tag::<_, _, OracleError<'_>>(".")), - ), - ), - value( - (), - terminated( - tag::<_, _, OracleError<'_>>("until end of turn"), - opt(tag::<_, _, OracleError<'_>>(".")), - ), - ), - ))) - .parse(rest) - .is_ok(); - // CR 614.1a + CR 615.5: A static prevention shield with a same-sentence - // imperative ("if damage would be dealt to , prevent that damage") - // closes the recipient phrase at the clause boundary `, prevent`, not at - // sentence end. `peek` acknowledges the boundary without consuming so - // the follow-up extractor still claims the imperative and its rider. - let clause_boundary = peek(tag::<_, _, OracleError<'_>>(", prevent")) - .parse(rest) - .is_ok(); - if fully_consumed || clause_boundary { - Ok((rest, filter)) - } else { - Err(nom::Err::Error(OracleError::new( - rest, - nom::error::ErrorKind::Verify, - ))) - } + let (rest, _) = parse_damage_recipient_terminator(rest)?; + Ok((rest, filter)) }) } @@ -15174,6 +15305,55 @@ mod tests { player: DamageTargetPlayerScope::Any }) ); + // CR 615.1a: permanent-or-player universal domain and optional-prevention boundary. + assert_eq!( + parse_damage_recipient_scope( + "if a spell would deal damage to a permanent or player, prevent 1 damage." + ), + Some(damage_target_any_permanent_or_player()) + ); + assert_eq!( + parse_damage_recipient_scope( + "if a source would deal damage to a player, you may prevent that damage." + ), + Some(DamageTargetFilter::Player { + player: DamageTargetPlayerScope::Any + }) + ); + // CR 615.1a: duration followed by same-sentence prevention imperative (Invulnerability). + assert_eq!( + parse_damage_recipient_scope( + "the next time a source of your choice would deal damage to you this turn, prevent that damage." + ), + Some(damage_target_controller()) + ); + assert_eq!( + parse_damage_recipient_scope( + "if a source would deal damage to you this combat, prevent that damage." + ), + Some(damage_target_controller()) + ); + assert_eq!( + parse_damage_recipient_scope( + "if a source would deal damage to a player until end of turn, you may prevent that damage." + ), + Some(DamageTargetFilter::Player { + player: DamageTargetPlayerScope::Any + }) + ); + // CR 615.1a: unconsumed trailing qualifiers fail closed rather than dropping restrictions. + assert_eq!( + parse_damage_recipient_scope( + "prevent all damage that would be dealt to creatures and planeswalkers you control." + ), + None + ); + assert_eq!( + parse_damage_recipient_scope( + "prevent all damage that would be dealt to creatures you control by sources you control." + ), + None + ); // No recipient clause → no scope (shield prevents all; behavior unchanged). assert_eq!( parse_damage_recipient_scope("prevent all combat damage this turn."), @@ -23198,6 +23378,19 @@ mod tests { "{phrase} must reach the shared conjunct authority" ); } + + // CR 109.1 + CR 615.1a: permanent-or-player universal domain. + for phrase in [ + "to a permanent or player", + "to that permanent or player", + "to a player or permanent", + "to that player or permanent", + ] { + let (rest, filter) = + parse_damage_target_phrase(phrase).expect("permanent or player must parse"); + assert!(rest.is_empty(), "{phrase} must be fully consumed"); + assert_eq!(filter, damage_target_any_permanent_or_player()); + } } #[test] @@ -27826,7 +28019,9 @@ mod snapshot_tests { #[cfg(test)] mod opposition_agent_parser_tests { use super::*; - use crate::types::ability::{CastingPermission, ManaSpendPermission, PermissionGrantee}; + use crate::types::ability::{ + CastingPermission, ManaSpendPermission, PermissionGrantee, RestrictionExpiry, ShieldKind, + }; use crate::types::card_type::CoreType; use crate::types::statics::{CastFrequency, ProhibitionScope, StaticMode}; @@ -28098,4 +28293,202 @@ mod opposition_agent_parser_tests { }) )); } + + #[test] + fn goblin_furrier_active_voice_damage_prevention_scoped_to_source_and_snow_recipients() { + // CR 615.1 + CR 615.1a: "Prevent all damage that this creature would deal to snow creatures." + // Must scope the source to SelfRef ("this creature") and the recipient to Snow creatures (CR 205.4a/g), + // with no duration (durable). + let def = parse_replacement_line( + "Prevent all damage that this creature would deal to snow creatures.", + "Goblin Furrier", + ) + .expect("Goblin Furrier prevention replacement must parse"); + + assert!(def.shield_kind.is_shield()); + assert_eq!( + def.shield_kind, + ShieldKind::Prevention { + amount: PreventionAmount::All + } + ); + assert_eq!( + def.damage_source_filter, + Some(TargetFilter::SelfRef), + "CR 615.1a: 'this creature' scopes the damage source to SelfRef" + ); + assert_eq!( + def.valid_card, + Some(TargetFilter::Typed(TypedFilter::creature().properties( + vec![FilterProp::HasSupertype { + value: Supertype::Snow, + }] + ))), + "CR 615.1a: recipient is snow creatures" + ); + assert_eq!( + def.damage_target_filter, None, + "object recipient is scoped via valid_card, not damage_target_filter" + ); + assert_eq!( + def.expiry, None, + "CR 604.2: durable static ability states no duration window" + ); + } + + #[test] + fn indentured_oaf_active_voice_damage_prevention_scoped_to_source_and_red_recipients() { + // CR 615.1 + CR 615.1a: "Prevent all damage that this creature would deal to red creatures." + // Sibling card in the active-voice self-reference class ("this creature would deal to "). + let def = parse_replacement_line( + "Prevent all damage that this creature would deal to red creatures.", + "Indentured Oaf", + ) + .expect("Indentured Oaf prevention replacement must parse"); + + assert!(def.shield_kind.is_shield()); + assert_eq!( + def.shield_kind, + ShieldKind::Prevention { + amount: PreventionAmount::All + } + ); + assert_eq!( + def.damage_source_filter, + Some(TargetFilter::SelfRef), + "CR 615.1a: 'this creature' scopes the damage source to SelfRef" + ); + assert_eq!( + def.valid_card, + Some(TargetFilter::Typed(TypedFilter::creature().properties( + vec![FilterProp::HasColor { + color: ManaColor::Red, + }] + ))), + "CR 615.1a: recipient is red creatures" + ); + assert_eq!( + def.damage_target_filter, None, + "object recipient is scoped via valid_card, not damage_target_filter" + ); + assert_eq!( + def.expiry, None, + "CR 604.2: durable static ability states no duration window" + ); + } + + #[test] + fn urzas_science_fair_project_active_voice_damage_prevention_scoped_to_source() { + // CR 615.1 + CR 615.1a: "Prevent all combat damage it would deal this turn." + // "it" scopes the damage source to SelfRef. + let def = parse_replacement_line( + "Prevent all combat damage it would deal this turn.", + "Urza's Science Fair Project", + ) + .expect("Urza's Science Fair Project row 2 must parse"); + + assert!(def.shield_kind.is_shield()); + assert_eq!( + def.damage_source_filter, + Some(TargetFilter::SelfRef), + "CR 615.1a: 'it' refers to the source object itself" + ); + assert_eq!(def.combat_scope, Some(CombatDamageScope::CombatOnly)); + assert_eq!(def.valid_card, None); + assert_eq!(def.damage_target_filter, None); + assert_eq!(def.expiry, Some(RestrictionExpiry::EndOfTurn)); + } + + #[test] + fn chameleon_blur_active_voice_damage_prevention_scoped_to_creatures_and_players() { + // CR 615.1 + CR 615.1a: "Prevent all damage that creatures would deal to players this turn." + // CR 615.2 + CR 609.7c: "creatures" specifies a source-property restriction on the damage source. + let def = parse_replacement_line( + "Prevent all damage that creatures would deal to players this turn.", + "Chameleon Blur", + ) + .expect("Chameleon Blur must parse"); + + assert!(def.shield_kind.is_shield()); + assert_eq!( + def.damage_source_filter, + Some(TargetFilter::Typed(TypedFilter::creature())), + "CR 615.2 + CR 609.7c: creatures are the damage source" + ); + assert_eq!( + def.damage_target_filter, + Some(DamageTargetFilter::Player { + player: DamageTargetPlayerScope::Any, + }), + "CR 615.1a: players are the damage recipient" + ); + assert_eq!(def.expiry, Some(RestrictionExpiry::EndOfTurn)); + } + + #[test] + fn western_cloud_and_light_of_sanction_recipient_qualifier_preservation() { + // CR 615.1a: The Western Cloud protects "creatures and planeswalkers you control". + // The recipient is represented by `valid_card` without narrow CreatureOnly + // `damage_target_filter` that would reject planeswalkers. + let wc = parse_replacement_line( + "Prevent all damage that would be dealt to creatures and planeswalkers you control.", + "The Western Cloud", + ) + .expect("The Western Cloud prevention replacement must parse"); + assert_eq!( + wc.damage_target_filter, None, + "CR 615.1a: must not narrow recipient to CreatureOnly (which rejects planeswalkers)" + ); + assert_eq!( + wc.valid_card, + Some(TargetFilter::Or { + filters: vec![ + TypedFilter::creature() + .controller(ControllerRef::You) + .into(), + TypedFilter::new(TypeFilter::Planeswalker) + .controller(ControllerRef::You) + .into(), + ] + }), + "CR 615.1a: recipient must include both creatures and planeswalkers you control" + ); + + // CR 615.1a: Light of Sanction contains unsupported controller-relative + // constraints ("creatures you control by sources you control"). The unconsumed + // source-clause residue must cause the replacement to fail closed (None) rather + // than dropping the qualifiers and emitting an overly broad prevention shield. + let ls = parse_replacement_line( + "Prevent all damage that would be dealt to creatures you control by sources you control.", + "Light of Sanction", + ); + assert_eq!( + ls, None, + "CR 615.1a: unsupported qualifiers must fail closed rather than dropping restrictions" + ); + } + + #[test] + fn invulnerability_damage_prevention_replacement_scoped_to_you_and_this_turn() { + // CR 615.1a: "The next time a source of your choice would deal damage to you this turn, prevent that damage." + // Recipient termination must compose duration "this turn" with ", prevent that damage" imperative. + let def = parse_replacement_line( + "The next time a source of your choice would deal damage to you this turn, prevent that damage.", + "Invulnerability", + ) + .expect("Invulnerability prevention replacement must parse"); + + assert!(def.shield_kind.is_shield()); + assert_eq!( + def.shield_kind, + ShieldKind::Prevention { + amount: PreventionAmount::All + } + ); + assert_eq!( + def.damage_target_filter, + Some(damage_target_controller()), + "CR 615.1a: recipient 'to you' scopes target to controller" + ); + } } diff --git a/crates/engine/tests/integration/goblin_furrier_snow_damage.rs b/crates/engine/tests/integration/goblin_furrier_snow_damage.rs new file mode 100644 index 0000000000..279bf1209c --- /dev/null +++ b/crates/engine/tests/integration/goblin_furrier_snow_damage.rs @@ -0,0 +1,504 @@ +//! Integration tests for the active-voice self-reference damage prevention class: +//! "Prevent all damage that this creature would deal to ." +//! +//! Card class members tested: +//! - Goblin Furrier: "Prevent all damage that this creature would deal to snow creatures." +//! - Indentured Oaf: "Prevent all damage that this creature would deal to red creatures." +//! +//! CR 615.1 / CR 615.1a (damage prevention effects with specific source and recipient scoping), +//! CR 205.4a & CR 205.4g (snow supertype), +//! CR 105.1 (color). +//! +//! Before this fix, active-voice prevention clauses of the form "Prevent all damage +//! that ~ / this creature would deal to " failed to isolate the damage source +//! (`damage_source_filter` was left None) and failed to parse "would deal to " (`valid_card` +//! was left None). Consequently, Goblin Furrier registered an unscoped, permanent prevention shield +//! that prevented ALL damage from any source to any target in the entire game. + +use engine::game::combat::AttackTarget; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::{FilterProp, PreventionAmount, ShieldKind, TargetFilter, TypedFilter}; +use engine::types::actions::GameAction; +use engine::types::card_type::Supertype; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::mana::ManaColor; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const GOBLIN_FURRIER_ORACLE: &str = + "Prevent all damage that this creature would deal to snow creatures."; + +const INDENTURED_OAF_ORACLE: &str = + "Prevent all damage that this creature would deal to red creatures."; + +#[must_use = "combat must be asserted to have actually run"] +fn run_combat( + runner: &mut GameRunner, + attacker_player: PlayerId, + attacks: &[(ObjectId, AttackTarget)], + defend_player: PlayerId, + blocks: &[(ObjectId, ObjectId)], +) -> bool { + let mut attacked = false; + let mut blocked = false; + let mut reached_end_of_combat = false; + + for _ in 0..400 { + match runner.state().phase { + Phase::EndCombat | Phase::PostCombatMain => { + reached_end_of_combat = true; + break; + } + _ => {} + } + match runner.state().waiting_for.clone() { + WaitingFor::Priority { .. } => { + if runner.act(GameAction::PassPriority).is_err() { + break; + } + } + WaitingFor::OrderTriggers { .. } => { + if runner + .act(GameAction::OrderTriggers { order: vec![0] }) + .is_err() + { + break; + } + } + WaitingFor::DeclareAttackers { player, .. } + if player == attacker_player && !attacked => + { + attacked = true; + runner + .declare_attackers(attacks) + .expect("declaring attackers must succeed"); + } + WaitingFor::DeclareAttackers { .. } => { + if runner.declare_attackers(&[]).is_err() { + break; + } + } + WaitingFor::DeclareBlockers { player, .. } if player == defend_player && !blocked => { + blocked = true; + runner + .declare_blockers(blocks) + .expect("declaring blockers must succeed"); + } + WaitingFor::DeclareBlockers { .. } => { + if runner.declare_blockers(&[]).is_err() { + break; + } + } + _ => break, + } + } + + attacked && (blocks.is_empty() || blocked) && reached_end_of_combat +} + +// =========================================================================== +// Goblin Furrier (Exact Oracle text: "Prevent all damage that this creature would deal to snow creatures.") +// =========================================================================== + +/// The user's exact reported bug: +/// Opponent controls Goblin Furrier. Player attacks with Ohran Yeti (Snow) and +/// two Korvikan Mists (non-snow), unblocked. +/// +/// CR 615.1a: Goblin Furrier only prevents damage dealt BY Goblin Furrier +/// TO snow creatures. It must NOT prevent combat damage dealt by other attacking +/// creatures to the defending player. +#[test] +fn goblin_furrier_does_not_prevent_damage_from_attacking_snow_and_nonsnow_creatures_to_player() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // Opponent controls Goblin Furrier + let goblin = scenario + .add_creature_from_oracle(P1, "Goblin Furrier", 2, 2, GOBLIN_FURRIER_ORACLE) + .id(); + + // Attacking player controls a Snow creature (Ohran Yeti 3/3) and 2 non-snow creatures (3/3 each) + let yeti = scenario.add_creature(P0, "Ohran Yeti", 3, 3).as_snow().id(); + let mist1 = scenario.add_creature(P0, "Korvikan Mist", 3, 3).id(); + let mist2 = scenario.add_creature(P0, "Korvikan Mist", 3, 3).id(); + + let mut runner = scenario.build(); + + // Reach-guard: Verify Goblin Furrier's ReplacementDefinition parsed with correct source and recipient filters + let defs = &runner.state().objects[&goblin].replacement_definitions; + assert_eq!( + defs.len(), + 1, + "Goblin Furrier must produce exactly 1 damage prevention ReplacementDefinition" + ); + let repl = &defs[0]; + assert_eq!( + repl.shield_kind, + ShieldKind::Prevention { + amount: PreventionAmount::All + }, + "prevention amount must be All" + ); + assert_eq!( + repl.damage_source_filter, + Some(TargetFilter::SelfRef), + "damage_source_filter must be scoped to Goblin Furrier (SelfRef)" + ); + assert_eq!( + repl.valid_card, + Some(TargetFilter::Typed(TypedFilter::creature().properties( + vec![FilterProp::HasSupertype { + value: Supertype::Snow, + }] + ))), + "valid_card filter must target snow creatures: {:?}", + repl.valid_card + ); + assert_eq!( + repl.damage_target_filter, None, + "damage_target_filter must be None (not player-directed)" + ); + + let p1_life_before = runner.life(P1); + runner.advance_to_combat(); + + let attacks = [ + (yeti, AttackTarget::Player(P1)), + (mist1, AttackTarget::Player(P1)), + (mist2, AttackTarget::Player(P1)), + ]; + assert!( + run_combat(&mut runner, P0, &attacks, P1, &[]), + "reach-guard: combat must run successfully with 3 attackers unblocked" + ); + runner.advance_until_stack_empty(); + + // 3 + 3 + 3 = 9 combat damage dealt to P1 + assert_eq!( + runner.life(P1), + p1_life_before - 9, + "Defending player must take full 9 combat damage from unblocked snow and non-snow attackers" + ); +} + +/// CR 615.1a: Goblin Furrier attacks and is blocked by a Snow creature (Ohran Yeti). +/// - Goblin Furrier's 2 damage to Ohran Yeti IS prevented. +/// - Ohran Yeti's 3 damage to Goblin Furrier is NOT prevented. +/// - Goblin Furrier takes lethal damage and dies. Ohran Yeti survives with 0 damage marked. +#[test] +fn goblin_furrier_damage_to_snow_creature_is_prevented_while_snow_creature_damages_furrier() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let goblin = scenario + .add_creature_from_oracle(P0, "Goblin Furrier", 2, 2, GOBLIN_FURRIER_ORACLE) + .id(); + let yeti = scenario.add_creature(P1, "Ohran Yeti", 3, 3).as_snow().id(); + + let mut runner = scenario.build(); + runner.advance_to_combat(); + + let attacks = [(goblin, AttackTarget::Player(P1))]; + let blocks = [(yeti, goblin)]; + assert!( + run_combat(&mut runner, P0, &attacks, P1, &blocks), + "reach-guard: combat must run successfully with Yeti blocking Furrier" + ); + runner.advance_until_stack_empty(); + + // Yeti takes 0 damage because Furrier's damage to snow creatures is prevented + assert_eq!( + runner.state().objects[&yeti].damage_marked, + 0, + "Ohran Yeti must have taken 0 marked damage from Goblin Furrier" + ); + assert_eq!( + runner.state().objects[&yeti].zone, + Zone::Battlefield, + "Ohran Yeti must remain on the battlefield" + ); + + // Goblin Furrier dies from Yeti's 3 combat damage (not prevented) + assert_eq!( + runner.state().objects[&goblin].zone, + Zone::Graveyard, + "Goblin Furrier must die from Ohran Yeti's 3 combat damage" + ); +} + +/// CR 615.1a: Goblin Furrier attacks and is blocked by a NON-SNOW creature (Grizzly Bears 2/2). +/// Both deal combat damage normally; both die to lethal combat damage. +#[test] +fn goblin_furrier_deals_combat_damage_normally_to_nonsnow_creature() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let goblin = scenario + .add_creature_from_oracle(P0, "Goblin Furrier", 2, 2, GOBLIN_FURRIER_ORACLE) + .id(); + let bears = scenario.add_creature(P1, "Grizzly Bears", 2, 2).id(); + + let mut runner = scenario.build(); + runner.advance_to_combat(); + + let attacks = [(goblin, AttackTarget::Player(P1))]; + let blocks = [(bears, goblin)]; + assert!( + run_combat(&mut runner, P0, &attacks, P1, &blocks), + "reach-guard: combat must run successfully with Bears blocking Furrier" + ); + runner.advance_until_stack_empty(); + + // Goblin Furrier deals 2 damage to Grizzly Bears (non-snow) -> Bears dies + assert_eq!( + runner.state().objects[&bears].zone, + Zone::Graveyard, + "Grizzly Bears must die from Goblin Furrier's combat damage" + ); + + // Bears deals 2 damage to Goblin Furrier -> Furrier dies + assert_eq!( + runner.state().objects[&goblin].zone, + Zone::Graveyard, + "Goblin Furrier must die from Grizzly Bears' combat damage" + ); +} + +/// CR 615.1a: Goblin Furrier attacks the defending player unblocked. +/// Goblin Furrier deals 2 combat damage normally (damage to players is not prevented). +#[test] +fn goblin_furrier_deals_combat_damage_normally_to_player() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let goblin = scenario + .add_creature_from_oracle(P0, "Goblin Furrier", 2, 2, GOBLIN_FURRIER_ORACLE) + .id(); + + let mut runner = scenario.build(); + let p1_life_before = runner.life(P1); + runner.advance_to_combat(); + + let attacks = [(goblin, AttackTarget::Player(P1))]; + assert!( + run_combat(&mut runner, P0, &attacks, P1, &[]), + "reach-guard: combat must run successfully with Furrier unblocked" + ); + runner.advance_until_stack_empty(); + + assert_eq!( + runner.life(P1), + p1_life_before - 2, + "Defending player must take 2 combat damage from Goblin Furrier" + ); +} + +/// CR 615.1a: Goblin Furrier blocks an attacking Snow creature (Ohran Yeti). +/// - Goblin Furrier's 2 damage to Ohran Yeti IS prevented. +/// - Ohran Yeti's 3 damage to Goblin Furrier is NOT prevented. +/// - Goblin Furrier dies; Yeti survives with 0 damage marked. +#[test] +fn goblin_furrier_blocking_snow_creature_prevents_furrier_damage_only() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let yeti = scenario.add_creature(P1, "Ohran Yeti", 3, 3).as_snow().id(); + let goblin = scenario + .add_creature_from_oracle(P0, "Goblin Furrier", 2, 2, GOBLIN_FURRIER_ORACLE) + .id(); + + let mut runner = scenario.build(); + runner.state_mut().active_player = P1; + runner.advance_to_combat(); + + let attacks = [(yeti, AttackTarget::Player(P0))]; + let blocks = [(goblin, yeti)]; + assert!( + run_combat(&mut runner, P1, &attacks, P0, &blocks), + "reach-guard: combat must run successfully with Furrier blocking Yeti" + ); + runner.advance_until_stack_empty(); + + assert_eq!( + runner.state().objects[&yeti].damage_marked, + 0, + "Ohran Yeti must have taken 0 marked damage from blocking Goblin Furrier" + ); + assert_eq!( + runner.state().objects[&yeti].zone, + Zone::Battlefield, + "Ohran Yeti must survive on the battlefield" + ); + assert_eq!( + runner.state().objects[&goblin].zone, + Zone::Graveyard, + "Goblin Furrier must die from Ohran Yeti's 3 combat damage" + ); +} + +// =========================================================================== +// Indentured Oaf (Exact Oracle text: "Prevent all damage that this creature would deal to red creatures.") +// Sibling card demonstrating the active-voice self-reference prevention class. +// =========================================================================== + +/// CR 615.1 + CR 615.1a: Indentured Oaf (4/3) attacks and is blocked by a red creature (Goblin Raider 2/2). +/// - Indentured Oaf's 4 combat damage to the red creature IS prevented. +/// - The red creature's 2 combat damage to Indentured Oaf is NOT prevented. +/// - Goblin Raider survives with 0 marked damage; Indentured Oaf survives with 2 marked damage. +#[test] +fn indentured_oaf_damage_to_red_creature_is_prevented_while_red_creature_damages_oaf() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let oaf = scenario + .add_creature_from_oracle(P0, "Indentured Oaf", 4, 3, INDENTURED_OAF_ORACLE) + .id(); + let raider = scenario + .add_creature(P1, "Goblin Raider", 2, 2) + .with_color(vec![ManaColor::Red]) + .id(); + + let mut runner = scenario.build(); + + // Reach-guard: Verify Indentured Oaf's ReplacementDefinition parsed with correct source and recipient filters + let defs = &runner.state().objects[&oaf].replacement_definitions; + assert_eq!( + defs.len(), + 1, + "Indentured Oaf must produce exactly 1 damage prevention ReplacementDefinition" + ); + let repl = &defs[0]; + assert_eq!( + repl.shield_kind, + ShieldKind::Prevention { + amount: PreventionAmount::All + }, + "prevention amount must be All" + ); + assert_eq!( + repl.damage_source_filter, + Some(TargetFilter::SelfRef), + "damage_source_filter must be scoped to Indentured Oaf (SelfRef)" + ); + assert_eq!( + repl.valid_card, + Some(TargetFilter::Typed(TypedFilter::creature().properties( + vec![FilterProp::HasColor { + color: ManaColor::Red, + }] + ))), + "valid_card filter must target red creatures: {:?}", + repl.valid_card + ); + + runner.advance_to_combat(); + + let attacks = [(oaf, AttackTarget::Player(P1))]; + let blocks = [(raider, oaf)]; + assert!( + run_combat(&mut runner, P0, &attacks, P1, &blocks), + "reach-guard: combat must run successfully with Raider blocking Oaf" + ); + runner.advance_until_stack_empty(); + + // Raider takes 0 damage because Oaf's damage to red creatures is prevented + assert_eq!( + runner.state().objects[&raider].damage_marked, + 0, + "Goblin Raider must have taken 0 marked damage from Indentured Oaf" + ); + assert_eq!( + runner.state().objects[&raider].zone, + Zone::Battlefield, + "Goblin Raider must survive on the battlefield" + ); + + // Indentured Oaf takes 2 damage from Raider (not prevented) + assert_eq!( + runner.state().objects[&oaf].damage_marked, + 2, + "Indentured Oaf must have taken 2 marked damage from Goblin Raider" + ); + assert_eq!( + runner.state().objects[&oaf].zone, + Zone::Battlefield, + "Indentured Oaf (toughness 3) must survive on the battlefield with 2 marked damage" + ); +} + +/// CR 615.1a: Indentured Oaf attacks and is blocked by a non-red creature (Grizzly Bears 2/2, Green). +/// Indentured Oaf deals 4 combat damage to Grizzly Bears normally; Grizzly Bears dies. +#[test] +fn indentured_oaf_deals_combat_damage_normally_to_nonred_creature() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let oaf = scenario + .add_creature_from_oracle(P0, "Indentured Oaf", 4, 3, INDENTURED_OAF_ORACLE) + .id(); + let bears = scenario + .add_creature(P1, "Grizzly Bears", 2, 2) + .with_color(vec![ManaColor::Green]) + .id(); + + let mut runner = scenario.build(); + runner.advance_to_combat(); + + let attacks = [(oaf, AttackTarget::Player(P1))]; + let blocks = [(bears, oaf)]; + assert!( + run_combat(&mut runner, P0, &attacks, P1, &blocks), + "reach-guard: combat must run successfully with Bears blocking Oaf" + ); + runner.advance_until_stack_empty(); + + // Indentured Oaf deals 4 damage to Grizzly Bears (non-red) -> Bears dies + assert_eq!( + runner.state().objects[&bears].zone, + Zone::Graveyard, + "Grizzly Bears must die from Indentured Oaf's combat damage" + ); + + // Bears deals 2 damage to Indentured Oaf -> Oaf survives with 2 damage marked + assert_eq!( + runner.state().objects[&oaf].damage_marked, + 2, + "Indentured Oaf must take 2 damage from Grizzly Bears" + ); + assert_eq!( + runner.state().objects[&oaf].zone, + Zone::Battlefield, + "Indentured Oaf must survive on the battlefield" + ); +} + +/// CR 615.1a: Indentured Oaf attacks the defending player unblocked. +/// Deals 4 combat damage normally (damage to players is not prevented). +#[test] +fn indentured_oaf_deals_combat_damage_normally_to_player() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let oaf = scenario + .add_creature_from_oracle(P0, "Indentured Oaf", 4, 3, INDENTURED_OAF_ORACLE) + .id(); + + let mut runner = scenario.build(); + let p1_life_before = runner.life(P1); + runner.advance_to_combat(); + + let attacks = [(oaf, AttackTarget::Player(P1))]; + assert!( + run_combat(&mut runner, P0, &attacks, P1, &[]), + "reach-guard: combat must run successfully with Oaf unblocked" + ); + runner.advance_until_stack_empty(); + + assert_eq!( + runner.life(P1), + p1_life_before - 4, + "Defending player must take 4 combat damage from Indentured Oaf" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 764a147db7..8d9145cbd6 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -360,6 +360,7 @@ mod glen_elendras_answer_counter_all_conjunction; mod gluntch_choose_player_chain; mod goad_badge_defender_gated_anchor; mod goaded_creature_under_pacifism_visible; +mod goblin_furrier_snow_damage; mod gollum_scheming_guide_card_predicate_guess; mod good_king_mog_xii_chapter_iv_588; mod gourmands_talent_turn_scoped_food_grant;