diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 144f4a89ba..b843dcd83f 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -258,6 +258,11 @@ pub(crate) fn is_data_carrying_static(mode: &StaticMode) -> bool { // Watchdog). Runtime enforcement is in morph::turn_face_up. Not // registry-keyed. | StaticMode::CantBeTurnedFaceUp + // CR 122.1d + CR 101.2: CountersCantBeRemoved carries the + // `CounterType` axis (Fear of Sleep Paralysis = Stun). Runtime + // enforcement is in turns.rs::counter_removal_blocked. Not + // registry-keyed. + | StaticMode::CountersCantBeRemoved { .. } ) } diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index b5a1f89f47..a4428e7da8 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -879,6 +879,45 @@ pub fn resolve_counter_match_for_removal( } } +/// CR 122.1d + CR 101.2: Returns `true` when an active +/// `CountersCantBeRemoved { counter_type }` static's `affected` filter matches +/// the given object for the given counter type. "Can't" effects take precedence +/// over any game action that would remove counters (Fear of Sleep Paralysis). +pub(crate) fn counter_removal_blocked( + state: &GameState, + object_id: ObjectId, + counter_type: &CounterType, +) -> bool { + use crate::types::statics::StaticMode; + crate::game::functioning_abilities::battlefield_active_statics(state).any( + |(source_obj, def)| { + if let StaticMode::CountersCantBeRemoved { + counter_type: ref ct, + } = def.mode + { + if ct != counter_type { + return false; + } + // `def.affected` is Option; None means "all permanents". + match &def.affected { + None => true, + Some(filter) => crate::game::static_abilities::static_filter_matches( + state, + &crate::game::static_abilities::StaticCheckContext { + target_id: Some(object_id), + ..Default::default() + }, + filter, + source_obj.id, + ), + } + } else { + false + } + }, + ) +} + /// CR 614.1: Remove counters from an object through the replacement pipeline. /// /// Single authority for counter removal, mirroring `add_counter_with_replacement`. @@ -896,6 +935,12 @@ pub fn remove_counter_with_replacement( count: u32, events: &mut Vec, ) { + // CR 101.2: "Can't" overrides "can" — if a static prohibits removal of + // this counter type from this object, bail out immediately. + if counter_removal_blocked(state, object_id, &counter_type) { + return; + } + let proposed = ProposedEvent::RemoveCounter { object_id, counter_type, @@ -1057,6 +1102,11 @@ fn move_counter_with_replacement_entry( if !counter_move_commit_is_valid(state, &counter_move) { return true; } + // CR 101.2: Moving counters away is removal from the source — if a + // "can't be removed" prohibition covers the source, block the move. + if counter_removal_blocked(state, counter_move.source_id, &counter_move.counter_type) { + return true; + } let proposed = ProposedEvent::MoveCounter { actor: counter_move.actor, source_id: counter_move.source_id, @@ -4861,4 +4911,297 @@ mod tests { "off-battlefield source's SelfRef replacement must not fire" ); } + + // ─── CountersCantBeRemoved gate tests ─────────────────────────────────── + + /// Install a CountersCantBeRemoved(Stun) static on `source_id` that + /// protects permanents controlled by the source's opponents. + fn install_counters_cant_be_removed_static(state: &mut GameState, source_id: ObjectId) { + use crate::types::ability::{ControllerRef, StaticDefinition, TargetFilter, TypedFilter}; + use crate::types::statics::StaticMode; + let def = StaticDefinition::new(StaticMode::CountersCantBeRemoved { + counter_type: CounterType::Stun, + }) + .affected(TargetFilter::Typed( + TypedFilter::permanent().controller(ControllerRef::Opponent), + )); + let obj = state.objects.get_mut(&source_id).unwrap(); + obj.static_definitions.push(def); + } + + /// CR 101.2: `remove_counter_with_replacement` is the single authority for + /// counter removal. When `CountersCantBeRemoved` prohibits removal, the + /// counter must remain and no event must fire. + #[test] + fn remove_counter_with_replacement_blocked_by_counters_cant_be_removed() { + let mut state = GameState::new_two_player(42); + + // Player 0 controls the prohibition source (enchantment). + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Fear of Sleep Paralysis".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&source) + .unwrap() + .card_types + .core_types + .push(CoreType::Enchantment); + install_counters_cant_be_removed_static(&mut state, source); + + // Player 1 controls a creature with a stun counter. + let target = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Stunned Bear".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&target) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + state + .objects + .get_mut(&target) + .unwrap() + .counters + .insert(CounterType::Stun, 2); + + let mut events = Vec::new(); + remove_counter_with_replacement(&mut state, target, CounterType::Stun, 1, &mut events); + + // Counter must remain unchanged. + assert_eq!( + state.objects[&target] + .counters + .get(&CounterType::Stun) + .copied(), + Some(2), + "stun counter must not be removed when blocked by CountersCantBeRemoved" + ); + // No removal event. + assert!( + !events.iter().any(|e| matches!( + e, + GameEvent::CounterRemoved { object_id, counter_type, .. } + if *object_id == target && *counter_type == CounterType::Stun + )), + "no CounterRemoved event when removal is blocked" + ); + } + + /// Inverse: without the prohibition, `remove_counter_with_replacement` + /// removes the counter normally. + #[test] + fn remove_counter_with_replacement_succeeds_without_prohibition() { + let mut state = GameState::new_two_player(42); + + let target = create_object( + &mut state, + CardId(1), + PlayerId(1), + "Stunned Bear".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&target) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + state + .objects + .get_mut(&target) + .unwrap() + .counters + .insert(CounterType::Stun, 1); + + let mut events = Vec::new(); + remove_counter_with_replacement(&mut state, target, CounterType::Stun, 1, &mut events); + + // Counter must be removed. + assert!( + !state.objects[&target] + .counters + .contains_key(&CounterType::Stun), + "stun counter must be removed when no prohibition exists" + ); + // Removal event must fire. + assert!( + events.iter().any(|e| matches!( + e, + GameEvent::CounterRemoved { object_id, counter_type, .. } + if *object_id == target && *counter_type == CounterType::Stun + )), + "CounterRemoved event must fire for baseline removal" + ); + } + + /// CR 101.2: Moving counters away is removal from the source. When + /// `CountersCantBeRemoved` protects the source, the move must be blocked. + #[test] + fn move_counter_blocked_by_counters_cant_be_removed() { + let mut state = GameState::new_two_player(42); + + // Player 0 controls the prohibition source. + let prohib = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Fear of Sleep Paralysis".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&prohib) + .unwrap() + .card_types + .core_types + .push(CoreType::Enchantment); + install_counters_cant_be_removed_static(&mut state, prohib); + + // Player 1 controls a creature with a stun counter (protected). + let source_perm = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Stunned Bear".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&source_perm) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + state + .objects + .get_mut(&source_perm) + .unwrap() + .counters + .insert(CounterType::Stun, 1); + + // Destination for the move. + let dest = create_object( + &mut state, + CardId(3), + PlayerId(1), + "Destination".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&dest) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + + let mut events = Vec::new(); + let result = move_counter_with_replacement( + &mut state, + PlayerId(1), + source_perm, + dest, + CounterType::Stun, + 1, + &mut events, + ); + + // Move returns true ("complete, nothing happened") but counter stays. + assert!(result, "move must return true when blocked"); + assert_eq!( + state.objects[&source_perm] + .counters + .get(&CounterType::Stun) + .copied(), + Some(1), + "stun counter must remain on source when move is blocked" + ); + assert!( + !state.objects[&dest] + .counters + .contains_key(&CounterType::Stun), + "destination must not receive the counter when move is blocked" + ); + } + + /// Inverse: without the prohibition, counter moves succeed normally. + #[test] + fn move_counter_succeeds_without_prohibition() { + let mut state = GameState::new_two_player(42); + + let source_perm = create_object( + &mut state, + CardId(1), + PlayerId(1), + "Stunned Bear".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&source_perm) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + state + .objects + .get_mut(&source_perm) + .unwrap() + .counters + .insert(CounterType::Stun, 1); + + let dest = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Destination".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&dest) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + + let mut events = Vec::new(); + let result = move_counter_with_replacement( + &mut state, + PlayerId(1), + source_perm, + dest, + CounterType::Stun, + 1, + &mut events, + ); + + assert!(result, "move must succeed without prohibition"); + assert!( + !state.objects[&source_perm] + .counters + .contains_key(&CounterType::Stun), + "stun counter must be removed from source after move" + ); + assert_eq!( + state.objects[&dest] + .counters + .get(&CounterType::Stun) + .copied(), + Some(1), + "destination must receive the counter after move" + ); + } } diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index 6ec939360f..36f1ec9c56 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -1265,23 +1265,35 @@ pub fn execute_untap_with_choices( match replacement::replace_event(state, proposed, events) { ReplacementResult::Execute(event) => { if let ProposedEvent::Untap { object_id, .. } = event { - if let Some(obj) = state.objects.get_mut(&object_id) { - // CR 122.1d: If a permanent with a stun counter would become untapped, - // instead remove a stun counter from it. - if let Some(entry) = obj.counters.get_mut(&CounterType::Stun) { - *entry -= 1; - if *entry == 0 { - obj.counters.remove(&CounterType::Stun); + let has_stun = state + .objects + .get(&object_id) + .is_some_and(|o| o.counters.contains_key(&CounterType::Stun)); + if has_stun { + // CR 122.1d + CR 101.2: Skip removal when blocked by + // CountersCantBeRemoved (Fear of Sleep Paralysis). + if !super::effects::counters::counter_removal_blocked( + state, + object_id, + &CounterType::Stun, + ) { + if let Some(obj) = state.objects.get_mut(&object_id) { + if let Some(entry) = obj.counters.get_mut(&CounterType::Stun) { + *entry -= 1; + if *entry == 0 { + obj.counters.remove(&CounterType::Stun); + } + } } events.push(GameEvent::CounterRemoved { object_id, counter_type: CounterType::Stun, count: 1, }); - } else { - obj.tapped = false; - events.push(GameEvent::PermanentUntapped { object_id }); } + } else if let Some(obj) = state.objects.get_mut(&object_id) { + obj.tapped = false; + events.push(GameEvent::PermanentUntapped { object_id }); } } } @@ -1577,23 +1589,35 @@ fn execute_seedborn_statics(state: &mut GameState, events: &mut Vec, match replacement::replace_event(state, proposed, events) { ReplacementResult::Execute(event) => { if let ProposedEvent::Untap { object_id, .. } = event { - if let Some(obj) = state.objects.get_mut(&object_id) { - // CR 122.1d: Stun-counter removal takes precedence - // over the untap, matching the main untap pass. - if let Some(entry) = obj.counters.get_mut(&CounterType::Stun) { - *entry -= 1; - if *entry == 0 { - obj.counters.remove(&CounterType::Stun); + let has_stun = state + .objects + .get(&object_id) + .is_some_and(|o| o.counters.contains_key(&CounterType::Stun)); + if has_stun { + // CR 122.1d + CR 101.2: Same gate as the main + // untap pass — skip removal when blocked. + if !super::effects::counters::counter_removal_blocked( + state, + object_id, + &CounterType::Stun, + ) { + if let Some(obj) = state.objects.get_mut(&object_id) { + if let Some(entry) = obj.counters.get_mut(&CounterType::Stun) { + *entry -= 1; + if *entry == 0 { + obj.counters.remove(&CounterType::Stun); + } + } } events.push(GameEvent::CounterRemoved { object_id, counter_type: CounterType::Stun, count: 1, }); - } else { - obj.tapped = false; - events.push(GameEvent::PermanentUntapped { object_id }); } + } else if let Some(obj) = state.objects.get_mut(&object_id) { + obj.tapped = false; + events.push(GameEvent::PermanentUntapped { object_id }); } } } @@ -8220,4 +8244,282 @@ mod tests { begin_phase_applied_count ); } + + /// CR 122.1d + CR 101.2: Fear of Sleep Paralysis — stun counters can't be + /// removed from permanents your opponents control. When the opponent's + /// creature would untap and has a stun counter, the counter stays and the + /// creature remains tapped. + #[test] + fn execute_untap_honors_counters_cant_be_removed_static() { + use crate::types::ability::{ControllerRef, StaticDefinition, TargetFilter, TypedFilter}; + use crate::types::counter::CounterType; + use crate::types::statics::StaticMode; + use crate::types::zones::Zone; + + let mut state = setup(); + // Player 1 is the active player (their untap step). + state.active_player = PlayerId(1); + + // Player 0 controls Fear of Sleep Paralysis (the source of the static). + let source = create_object( + &mut state, + CardId(10), + PlayerId(0), + "Fear of Sleep Paralysis".to_string(), + Zone::Battlefield, + ); + { + let def = StaticDefinition::new(StaticMode::CountersCantBeRemoved { + counter_type: CounterType::Stun, + }) + .affected(TargetFilter::Typed( + TypedFilter::permanent().controller(ControllerRef::Opponent), + )); + let obj = state.objects.get_mut(&source).unwrap(); + obj.card_types + .core_types + .push(crate::types::card_type::CoreType::Enchantment); + obj.static_definitions.push(def); + } + + // Player 1 controls a creature with a stun counter, tapped. + let stunned = create_object( + &mut state, + CardId(11), + PlayerId(1), + "Stunned Bear".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&stunned).unwrap(); + obj.card_types + .core_types + .push(crate::types::card_type::CoreType::Creature); + obj.tapped = true; + obj.counters.insert(CounterType::Stun, 1); + } + + let mut events = Vec::new(); + execute_untap(&mut state, &mut events); + + // The creature must stay tapped — the stun counter blocks the untap. + assert!( + state.objects[&stunned].tapped, + "creature with blocked stun counter must stay tapped" + ); + // The stun counter must NOT have been removed. + assert_eq!( + state.objects[&stunned].counters.get(&CounterType::Stun), + Some(&1), + "stun counter must remain when removal is blocked" + ); + // No CounterRemoved event should have been emitted. + assert!( + !events.iter().any(|e| matches!( + e, + GameEvent::CounterRemoved { object_id, .. } if *object_id == stunned + )), + "no CounterRemoved event when removal is blocked" + ); + } + + /// Inverse test: without Fear of Sleep Paralysis, a stunned creature's stun + /// counter IS removed at the untap step (CR 122.1d baseline). + #[test] + fn execute_untap_removes_stun_counter_without_prohibition() { + use crate::types::counter::CounterType; + use crate::types::zones::Zone; + + let mut state = setup(); + state.active_player = PlayerId(1); + + let stunned = create_object( + &mut state, + CardId(11), + PlayerId(1), + "Stunned Bear".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&stunned).unwrap(); + obj.tapped = true; + obj.counters.insert(CounterType::Stun, 1); + } + + let mut events = Vec::new(); + execute_untap(&mut state, &mut events); + + // The creature stays tapped (stun counter was removed instead of untapping). + assert!( + state.objects[&stunned].tapped, + "creature stays tapped when stun counter is removed (CR 122.1d)" + ); + // The stun counter must have been removed. + assert!( + !state.objects[&stunned] + .counters + .contains_key(&CounterType::Stun), + "stun counter must be removed at untap step (CR 122.1d baseline)" + ); + // A CounterRemoved event must have been emitted. + assert!( + events.iter().any(|e| matches!( + e, + GameEvent::CounterRemoved { object_id, counter_type, .. } + if *object_id == stunned && *counter_type == CounterType::Stun + )), + "CounterRemoved event must fire for baseline stun removal" + ); + } + + /// CR 122.1d + CR 101.2: The Seedborn Muse untap path honors + /// `CountersCantBeRemoved` — a stunned opponent permanent protected by the + /// prohibition keeps its stun counter even during the Seedborn pass. + #[test] + fn execute_seedborn_statics_honors_counters_cant_be_removed() { + use crate::types::ability::{ControllerRef, StaticDefinition, TargetFilter, TypedFilter}; + use crate::types::counter::CounterType; + use crate::types::statics::StaticMode; + use crate::types::zones::Zone; + + let mut state = setup(); + // Player 0 is the active player (their untap step). + state.active_player = PlayerId(0); + + // Player 1 controls Seedborn Muse — untaps their stuff during P0's step. + let seedborn = create_object( + &mut state, + CardId(1), + PlayerId(1), + "Seedborn Muse".to_string(), + Zone::Battlefield, + ); + install_seedborn_static(&mut state, seedborn); + mark_as_creature(&mut state, seedborn); + + // Player 1 also controls a stunned creature. + let stunned = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Stunned Bear".to_string(), + Zone::Battlefield, + ); + mark_as_creature(&mut state, stunned); + state.objects.get_mut(&stunned).unwrap().tapped = true; + state + .objects + .get_mut(&stunned) + .unwrap() + .counters + .insert(CounterType::Stun, 1); + + // Player 0 controls the prohibition (Fear of Sleep Paralysis). + // Its filter is "permanents your opponents control" — Player 1's + // permanents are opponents of Player 0. + let prohib = create_object( + &mut state, + CardId(3), + PlayerId(0), + "Fear of Sleep Paralysis".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&prohib) + .unwrap() + .card_types + .core_types + .push(crate::types::card_type::CoreType::Enchantment); + let def = StaticDefinition::new(StaticMode::CountersCantBeRemoved { + counter_type: CounterType::Stun, + }) + .affected(TargetFilter::Typed( + TypedFilter::permanent().controller(ControllerRef::Opponent), + )); + state + .objects + .get_mut(&prohib) + .unwrap() + .static_definitions + .push(def); + + let mut events = Vec::new(); + execute_untap(&mut state, &mut events); + + // The stun counter must remain — the Seedborn pass is blocked. + assert_eq!( + state.objects[&stunned] + .counters + .get(&CounterType::Stun) + .copied(), + Some(1), + "Seedborn pass must not remove stun counter when blocked by CountersCantBeRemoved" + ); + // The creature must remain tapped (stun counter prevents untap). + assert!( + state.objects[&stunned].tapped, + "stunned creature must stay tapped during Seedborn pass when counter removal is blocked" + ); + } + + /// Inverse: without the prohibition, the Seedborn Muse pass removes the + /// stun counter normally per CR 122.1d. + #[test] + fn execute_seedborn_statics_removes_stun_counter_without_prohibition() { + use crate::types::counter::CounterType; + use crate::types::zones::Zone; + + let mut state = setup(); + // Player 0 is the active player (their untap step). + state.active_player = PlayerId(0); + + // Player 1 controls Seedborn Muse. + let seedborn = create_object( + &mut state, + CardId(1), + PlayerId(1), + "Seedborn Muse".to_string(), + Zone::Battlefield, + ); + install_seedborn_static(&mut state, seedborn); + mark_as_creature(&mut state, seedborn); + + // Player 1 also controls a stunned creature. + let stunned = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Stunned Bear".to_string(), + Zone::Battlefield, + ); + mark_as_creature(&mut state, stunned); + state.objects.get_mut(&stunned).unwrap().tapped = true; + state + .objects + .get_mut(&stunned) + .unwrap() + .counters + .insert(CounterType::Stun, 1); + + let mut events = Vec::new(); + execute_untap(&mut state, &mut events); + + // Without prohibition, the stun counter is removed per CR 122.1d. + assert!( + !state.objects[&stunned] + .counters + .contains_key(&CounterType::Stun), + "stun counter must be removed during Seedborn pass (CR 122.1d baseline)" + ); + // A CounterRemoved event must have been emitted. + assert!( + events.iter().any(|e| matches!( + e, + GameEvent::CounterRemoved { object_id, counter_type, .. } + if *object_id == stunned && *counter_type == CounterType::Stun + )), + "CounterRemoved event must fire for Seedborn baseline stun removal" + ); + } } diff --git a/crates/engine/src/parser/oracle_static/dispatch.rs b/crates/engine/src/parser/oracle_static/dispatch.rs index 52aa15dd1c..f5f2c0b56c 100644 --- a/crates/engine/src/parser/oracle_static/dispatch.rs +++ b/crates/engine/src/parser/oracle_static/dispatch.rs @@ -2668,6 +2668,12 @@ pub(crate) fn parse_static_line_inner( return Some(def); } + // CR 122.1d + CR 101.2: " counters can't be removed from ." + // (Fear of Sleep Paralysis) — counter-removal prohibition. + if let Some(def) = parse_counters_cant_be_removed_static(&tp, &text) { + return Some(def); + } + // NOTE: "enters with N counters" patterns are now handled by oracle_replacement.rs // as proper Moved replacement effects (paralleling the "enters tapped" pattern). @@ -3567,3 +3573,43 @@ pub(crate) fn try_parse_counts_as_named_static(text: &str) -> Option counters can't be removed from ." +/// (Fear of Sleep Paralysis) — counter-removal prohibition. Parses the Oracle +/// text pattern and builds a `StaticMode::CountersCantBeRemoved` static whose +/// `affected` filter scopes the protected permanents. +fn parse_counters_cant_be_removed_static( + tp: &TextPair<'_>, + text: &str, +) -> Option { + // Composed grammar (CR 122.1d + CR 101.2): + // " counters can't be removed from " [.] EOF + // Uses nom combinators for the counter-type prefix and the fixed anchor, + // then parse_type_phrase for the subject (same pattern as + // parse_damage_not_removed_during_cleanup). + + // Step 1: Parse the counter type from the start of the lowercase text. + let (after_counter, counter_type) = nom_primitives::parse_strict_counter_type(tp.lower).ok()?; + + // Step 2: Consume the fixed anchor via nom_tag_lower. + let body = nom_tag_lower( + after_counter, + after_counter, + " counters can't be removed from ", + )?; + + // Step 3: Parse the subject from the original-case text at the same byte + // offset as `body` within `tp.lower`. + let consumed = tp.lower.len() - body.len(); + let subject_original = tp.original[consumed..].trim_end_matches('.').trim(); + let (filter, remainder) = parse_type_phrase(subject_original); + if matches!(&filter, TargetFilter::Any) || !remainder.trim().is_empty() { + return None; + } + + Some( + StaticDefinition::new(StaticMode::CountersCantBeRemoved { counter_type }) + .affected(filter) + .description(text.to_string()), + ) +} diff --git a/crates/engine/src/types/statics.rs b/crates/engine/src/types/statics.rs index 436fe68e37..e988b1e005 100644 --- a/crates/engine/src/types/statics.rs +++ b/crates/engine/src/types/statics.rs @@ -1924,6 +1924,15 @@ pub enum StaticMode { counter_type: super::counter::CounterType, count: u32, }, + /// CR 122.1d + CR 101.2: Counters of the specified type cannot be removed + /// from permanents matching the `StaticDefinition::affected` filter. The + /// runtime gate lives in `turns.rs` (untap-step stun-counter removal) and + /// generalizes to any `CounterType` axis (Fear of Sleep Paralysis = Stun). Runtime + /// enforcement prevents the counter from being removed during the untap step; + /// the creature stays tapped. + CountersCantBeRemoved { + counter_type: super::counter::CounterType, + }, /// Odyssey Burst-cycle graveyard name-aliasing (no general CR governs this /// templating; name-matching semantics per CR 201.2a). While this card is in /// a graveyard, effects that count "cards named X" treat it as having the @@ -2061,6 +2070,7 @@ pub enum StaticModeKind { UntapsDuringEachOtherPlayersUntapStep, MaxUntapPerType, EntersWithAdditionalCounters, + CountersCantBeRemoved, CountsAsNamed, Other, } @@ -2207,6 +2217,7 @@ impl StaticMode { StaticMode::EntersWithAdditionalCounters { .. } => { StaticModeKind::EntersWithAdditionalCounters } + StaticMode::CountersCantBeRemoved { .. } => StaticModeKind::CountersCantBeRemoved, StaticMode::CountsAsNamed { .. } => StaticModeKind::CountsAsNamed, StaticMode::Other(..) => StaticModeKind::Other, } @@ -2407,6 +2418,9 @@ impl Hash for StaticMode { // CR 614.1c: data-carrying (CounterType + count); consumed by direct // match in change_zone.rs, never used as a HashMap key. | StaticMode::EntersWithAdditionalCounters { .. } + // CR 122.1d: data-carrying (CounterType); consumed by direct match + // in turns.rs counter_removal_blocked, never used as a HashMap key. + | StaticMode::CountersCantBeRemoved { .. } // Odyssey Burst-cycle (CR 201.2a): data-carrying (name alias); // consumed by direct match in filter.rs, never used as a HashMap key. | StaticMode::CountsAsNamed { .. } @@ -2539,6 +2553,7 @@ impl StaticMode { | StaticMode::UntapsDuringEachOtherPlayersUntapStep | StaticMode::MaxUntapPerType { .. } | StaticMode::EntersWithAdditionalCounters { .. } + | StaticMode::CountersCantBeRemoved { .. } | StaticMode::CountsAsNamed { .. } | StaticMode::LinkedCollectionCounterPlayPermission | StaticMode::DamageNotRemovedDuringCleanup @@ -2945,6 +2960,9 @@ impl fmt::Display for StaticMode { } => { write!(f, "EntersWithAdditionalCounters({counter_type:?},{count})") } + StaticMode::CountersCantBeRemoved { ref counter_type } => { + write!(f, "CountersCantBeRemoved({counter_type:?})") + } StaticMode::LinkedCollectionCounterPlayPermission => { write!(f, "LinkedCollectionCounterPlayPermission") } @@ -3474,6 +3492,10 @@ impl FromStr for StaticMode { // FromStr inverse; round-trip is diagnostic-only and callers // read the typed field. Mirrors MaximumHandSize / SuppressTriggers. return Ok(StaticMode::Other(other.to_string())); + } else if other.starts_with("CountersCantBeRemoved(") { + // CR 122.1d: Data-carrying (CounterType). Same diagnostic-only + // round-trip as EntersWithAdditionalCounters above. + return Ok(StaticMode::Other(other.to_string())); } else if let Some(inner) = other .strip_prefix("CantCastDuring(") .and_then(|s| s.strip_suffix(')'))