From 87758a20c1f9f22b511a2440c9560a057ac6134c Mon Sep 17 00:00:00 2001 From: philluiz2323 Date: Wed, 17 Jun 2026 15:14:32 -0700 Subject: [PATCH] feat(engine): support "damage isn't removed during cleanup" statics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 514.2: removing all marked damage is a turn-based action during the cleanup step. "Damage isn't removed from [subject] during cleanup steps" statics (Ancient Adamantoise, Patient Zero, Uthgardt Fury, Case of the Market Melee) suppress that removal for the permanents they match, so marked damage persists across turns. The engine had no mechanism — execute_cleanup cleared every permanent's damage_marked unconditionally — so these cards were unsupported. - Add StaticMode::DamageNotRemovedDuringCleanup, a nullary marker whose subject rides the definition's affected filter ("this creature" -> SelfRef; "creatures your opponents control" -> a typed filter). Classified data-carrying in coverage (enforced by a bespoke turn-based hook, not the layer registry). - Parser parse_damage_not_removed_during_cleanup composes the sanctioned scan_contains / split_once_on_lower / parse_type_phrase building blocks. - execute_cleanup gathers permanents matched by an active such static (via battlefield_active_statics + matches_target_filter) and skips clearing their damage. Tests: parser (self + filtered subject) and a runtime cleanup scenario asserting a protected permanent keeps its marked damage while a normal creature's is removed. Full engine lib suite green (12413 passed); local regen: 4 cards flip to supported, 0 regressions. Closes #3644 --- crates/engine/src/game/coverage.rs | 7 +- crates/engine/src/game/turns.rs | 161 ++++++++++++++++++ .../src/parser/oracle_static/dispatch.rs | 48 ++++++ .../engine/src/parser/oracle_static/tests.rs | 43 +++++ crates/engine/src/types/statics.rs | 11 ++ data/engine-inventory.json | 10 ++ 6 files changed, 279 insertions(+), 1 deletion(-) 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,