diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 6b9bfa5786..fae2a1a54f 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -44,7 +44,12 @@ use std::collections::{BTreeMap, HashMap, HashSet}; fn is_data_carrying_static(mode: &StaticMode) -> bool { matches!( mode, - StaticMode::ReduceAbilityCost { .. } + // CR 514.2: nullary marker static — runtime enforcement is the cleanup + // turn-based action in turns.rs::execute_cleanup, which skips removing + // marked damage from permanents matching an active such static's + // `affected` filter. Not registry-keyed (mirrors the marker cluster). + StaticMode::DamageNotRemovedDuringCleanup + | StaticMode::ReduceAbilityCost { .. } | StaticMode::ModifyActivationLimit { .. } | StaticMode::AdditionalLandDrop { .. } | StaticMode::ModifyCost { .. } diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index aec00543ac..1e88f60bf9 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -1359,11 +1359,46 @@ pub fn execute_cleanup(state: &mut GameState, events: &mut Vec) -> Op } } + // CR 514.2: An active "Damage isn't removed from [filter] during cleanup + // steps" static (Ancient Adamantoise, Patient Zero, Uthgardt Fury, …) + // suppresses removal for the permanents it matches; their marked damage + // persists across turns. Gather that protected set first. + let damage_persists: std::collections::HashSet = { + use crate::game::filter::{matches_target_filter_including_phased_out, FilterContext}; + use crate::types::ability::TargetFilter; + + let sources: Vec<(ObjectId, PlayerId, TargetFilter)> = + super::functioning_abilities::battlefield_active_statics(state) + .filter(|(_, def)| matches!(def.mode, StaticMode::DamageNotRemovedDuringCleanup)) + .filter_map(|(obj, def)| { + def.affected + .as_ref() + .map(|f| (obj.id, obj.controller, f.clone())) + }) + .collect(); + + // CR 514.2 + CR 702.26b: removing marked damage is a turn-based cleanup + // action over the whole battlefield, including phased-out permanents — it + // is not targeting — so the protected membership is evaluated with the + // phased-out-aware matcher to mirror the unconditional removal below. + let mut protected = std::collections::HashSet::new(); + for (source_id, source_controller, filter) in sources { + let ctx = FilterContext::from_source_with_controller(source_id, source_controller); + for id in state.battlefield.iter().copied() { + if matches_target_filter_including_phased_out(state, id, &filter, &ctx) { + protected.insert(id); + } + } + } + protected + }; + // CR 514.2: Damage on creatures is removed at cleanup. let to_clear: Vec<_> = state .battlefield .iter() .copied() + .filter(|id| !damage_persists.contains(id)) .filter(|id| { state .objects @@ -4589,6 +4624,132 @@ mod tests { assert_eq!(state.objects[&id].damage_marked, 0); } + #[test] + fn execute_cleanup_preserves_damage_under_damage_not_removed_static() { + use crate::types::card_type::CoreType; + + let mut state = setup(); + + // Ancient-Adamantoise-style permanent: its own damage isn't removed at + // cleanup. CR 514.2 — the static suppresses the turn-based removal. + let protected = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Ancient Adamantoise".to_string(), + Zone::Battlefield, + ); + { + let defs = crate::parser::oracle_static::parse_static_line_multi( + "Damage isn't removed from this creature during cleanup steps.", + ); + assert!( + defs.iter() + .any(|d| d.mode == StaticMode::DamageNotRemovedDuringCleanup), + "static must parse to DamageNotRemovedDuringCleanup, got {:?}", + defs.iter().map(|d| &d.mode).collect::>() + ); + let obj = state.objects.get_mut(&protected).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.damage_marked = 4; + for def in defs.iter().cloned() { + obj.static_definitions.push(def); + } + Arc::make_mut(&mut obj.base_static_definitions).extend(defs); + } + + // A normal creature: its damage IS removed at cleanup (control). + let normal = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Grizzly Bears".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&normal).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.damage_marked = 3; + } + + let mut events = Vec::new(); + execute_cleanup(&mut state, &mut events); + + assert_eq!( + state.objects[&protected].damage_marked, 4, + "damage must persist under DamageNotRemovedDuringCleanup" + ); + assert_eq!( + state.objects[&normal].damage_marked, 0, + "a normal creature's damage is still removed at cleanup" + ); + } + + #[test] + fn execute_cleanup_preserves_phased_out_creature_damage_under_static() { + use crate::game::game_object::{PhaseOutCause, PhaseStatus}; + use crate::types::card_type::CoreType; + + let mut state = setup(); + state.active_player = PlayerId(0); + + // Patient Zero: "Damage isn't removed from creatures your opponents + // control during cleanup steps." — the static source (controlled by P0). + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Patient Zero".to_string(), + Zone::Battlefield, + ); + { + let defs = crate::parser::oracle_static::parse_static_line_multi( + "Damage isn't removed from creatures your opponents control during cleanup steps.", + ); + assert!(defs + .iter() + .any(|d| d.mode == StaticMode::DamageNotRemovedDuringCleanup)); + let obj = state.objects.get_mut(&source).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + for def in defs.iter().cloned() { + obj.static_definitions.push(def); + } + Arc::make_mut(&mut obj.base_static_definitions).extend(defs); + } + + // A phased-out opponent creature with marked damage. CR 514.2 + CR + // 702.26b: damage removal at cleanup is a turn-based action over the + // whole battlefield (including phased-out permanents), so the static must + // preserve this creature's damage even while it is phased out. + let phased = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Grizzly Bears".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&phased).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.damage_marked = 3; + obj.phase_status = PhaseStatus::PhasedOut { + cause: PhaseOutCause::Directly, + }; + } + + let mut events = Vec::new(); + execute_cleanup(&mut state, &mut events); + + assert_eq!( + state.objects[&phased].damage_marked, 3, + "a phased-out opponent creature's damage must persist under the static" + ); + } + /// CR 117.1c + CR 503.2: After Untap (no priority), the engine must hand /// the active player priority during Upkeep — even when no triggers fired. /// Previously `auto_advance` skipped past empty Upkeep/Draw windows, which diff --git a/crates/engine/src/parser/oracle_static/dispatch.rs b/crates/engine/src/parser/oracle_static/dispatch.rs index d9a380c34b..382f00347c 100644 --- a/crates/engine/src/parser/oracle_static/dispatch.rs +++ b/crates/engine/src/parser/oracle_static/dispatch.rs @@ -223,6 +223,50 @@ fn parse_reveal_hand_static(tp: &TextPair<'_>, text: &str) -> Option Option { + // Composed grammar (CR 514.2): + // "damage isn't removed from " " during cleanup steps" [.] EOF + // where is a self-reference or a type phrase. The cleanup-step + // suffix is anchored at end-of-sentence (nothing but an optional period may + // follow), so a sentence that merely mentions "cleanup" later is rejected + // and the subject must parse to completion. + let body = nom_tag_lower(tp.lower, tp.lower, "damage isn't removed from ")?; + + let (affected, after_subject) = if let Some(rest) = nom_tag_lower(body, body, "~") + .or_else(|| nom_tag_lower(body, body, "this creature")) + .or_else(|| nom_tag_lower(body, body, "this permanent")) + { + (TargetFilter::SelfRef, rest) + } else { + let (filter, rest) = parse_type_phrase(body); + if matches!(&filter, TargetFilter::Any) { + return None; + } + (filter, rest) + }; + + let tail = nom_tag_lower(after_subject, after_subject, " during cleanup steps")?; + if !tail.trim_end_matches('.').trim().is_empty() { + return None; + } + + Some( + StaticDefinition::new(StaticMode::DamageNotRemovedDuringCleanup) + .affected(affected) + .description(text.to_string()), + ) +} + pub(crate) fn parse_static_line_inner( text: &str, inverted: InvertedAsLongAs, @@ -234,6 +278,10 @@ pub(crate) fn parse_static_line_inner( if let Some(def) = parse_arcane_adaptation_chosen_type_static(&tp, &text) { return Some(def); } + // CR 514.2: "Damage isn't removed from [subject] during cleanup steps." + if let Some(def) = parse_damage_not_removed_during_cleanup(&tp, &text) { + return Some(def); + } // CR 101.2 + CR 109.5: "Each opponent who [did X] this turn can't [Y]" — // per-affected-player conditional prohibition (Angelic Arbiter). Must run // BEFORE the generic "can't attack" arm and the `parse_cant_cast_type_spells` diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 358f0d93ae..b45276e15a 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -18131,3 +18131,46 @@ fn ichormoon_gauntlet_grants_loyalty_abilities_to_planeswalkers() { grants[1].effect ); } + +#[test] +fn damage_not_removed_during_cleanup_self_subject() { + // CR 514.2: "this creature" subject → SelfRef-affected DamageNotRemoved static. + let def = parse_static_line("Damage isn't removed from this creature during cleanup steps.") + .expect("self-subject cleanup-damage static"); + assert_eq!(def.mode, StaticMode::DamageNotRemovedDuringCleanup); + assert_eq!(def.affected, Some(TargetFilter::SelfRef)); +} + +#[test] +fn damage_not_removed_during_cleanup_filtered_subject() { + // CR 514.2: a typed subject ("creatures your opponents control") is carried + // as the affected filter (Patient Zero), not collapsed to SelfRef. + let def = parse_static_line( + "Damage isn't removed from creatures your opponents control during cleanup steps.", + ) + .expect("filtered cleanup-damage static"); + assert_eq!(def.mode, StaticMode::DamageNotRemovedDuringCleanup); + assert!( + !matches!(def.affected, Some(TargetFilter::SelfRef) | None), + "affected must be the typed opponent-creatures filter, got {:?}", + def.affected + ); +} + +#[test] +fn damage_not_removed_during_cleanup_rejects_non_cleanup_during() { + // CR 514.2: the grammar anchors on the "during cleanup steps" suffix, so a + // "during " sentence that merely mentions cleanup elsewhere + // must NOT be classified as a cleanup-damage static. + let def = parse_static_line( + "Damage isn't removed from creatures during combat this turn. Skip your cleanup step.", + ); + assert!( + !matches!( + def.as_ref().map(|d| &d.mode), + Some(StaticMode::DamageNotRemovedDuringCleanup) + ), + "a non-\"during cleanup steps\" sentence must not be a cleanup-damage static, got {:?}", + def.map(|d| d.mode) + ); +} diff --git a/crates/engine/src/types/statics.rs b/crates/engine/src/types/statics.rs index 97c2bbd90b..9d6a4b40f8 100644 --- a/crates/engine/src/types/statics.rs +++ b/crates/engine/src/types/statics.rs @@ -611,6 +611,13 @@ pub enum CombatAloneRequirement { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum StaticMode { Continuous, + /// CR 514.2: Normally all damage marked on permanents is removed as a + /// turn-based action during the cleanup step. This static suppresses that + /// removal for the permanents matched by the definition's `affected` filter + /// ("Damage isn't removed from [filter] during cleanup steps" — Ancient + /// Adamantoise, Patient Zero, Uthgardt Fury, …), so their marked damage + /// persists across turns. + DamageNotRemovedDuringCleanup, CantAttack, CantBlock, CantAttackOrBlock, @@ -1693,6 +1700,7 @@ impl StaticMode { | StaticMode::MaxUntapPerType { .. } | StaticMode::EntersWithAdditionalCounters { .. } | StaticMode::LinkedCollectionCounterPlayPermission + | StaticMode::DamageNotRemovedDuringCleanup | StaticMode::Other(_) => None, } } @@ -1702,6 +1710,9 @@ impl fmt::Display for StaticMode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { StaticMode::Continuous => write!(f, "Continuous"), + StaticMode::DamageNotRemovedDuringCleanup => { + write!(f, "DamageNotRemovedDuringCleanup") + } StaticMode::CantAttack => write!(f, "CantAttack"), StaticMode::CantBlock => write!(f, "CantBlock"), StaticMode::CantAttackOrBlock => write!(f, "CantAttackOrBlock"), diff --git a/data/engine-inventory.json b/data/engine-inventory.json index 04154f44fc..4388dcc64c 100644 --- a/data/engine-inventory.json +++ b/data/engine-inventory.json @@ -29278,6 +29278,16 @@ "doc": "", "cr_refs": [] }, + { + "name": "DamageNotRemovedDuringCleanup", + "line": 620, + "kind": "unit", + "field_names": [], + "doc": "CR 514.2: Normally all damage marked on permanents is removed as a turn-based action during the cleanup step. This static suppresses that removal for the permanents matched by the definition's `affected` filter (\"Damage isn't removed from [filter] during cleanup steps\" — Ancient Adamantoise, Patient Zero, Uthgardt Fury, …), so their marked damage persists across turns.", + "cr_refs": [ + "CR 514.2" + ] + }, { "name": "CantAttack", "line": 614,