diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 044ac381d6..653024513c 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -2225,6 +2225,68 @@ pub(super) fn selected_static_permission_enters_with_counter( }) } +// CR 614.1a + CR 607.1: Shared single-authority extractor for the granted +// leave-the-battlefield-exile replacement rider carried on a +// `GraveyardCastPermission` / `ExileCastPermission` static. Mirrors the +// `permission_counter` inner fn shape. Returns the cloned boxed def (`None` when +// the permission carries no rider). +fn permission_granted_replacement( + def: &StaticDefinition, +) -> Option> { + match &def.mode { + StaticMode::GraveyardCastPermission { + granted_replacement, + .. + } => granted_replacement.clone(), + // `ExileCastPermission` carries no `granted_replacement` field today; if a + // printed exile-cast carrier ships, the field + arm slot in here. + _ => None, + } +} + +// CR 614.1a + CR 608.2c + CR 607.1: Spell path — read the granted leave-the- +// battlefield-exile replacement rider from the *selected* cast permission (the +// one authorizing THIS cast, embedded in `casting_variant`) so a non-consumed +// sibling permission's rider cannot leak (CR 608.2c). Mirrors +// `selected_static_permission_enters_with_counter`, including its CR 607.1 +// self-granting `.or_else` fallback (a self-granting source is the cast object, +// now on the Stack, so its Graveyard-scoped permission no longer functions in +// zone — read the rider directly from the committed authorizing definition). +pub(super) fn selected_static_permission_granted_replacement( + state: &GameState, + casting_variant: &crate::types::game_state::CastingVariant, +) -> Option> { + use crate::types::game_state::CastingVariant; + let source = match casting_variant { + CastingVariant::GraveyardPermission { source, .. } + | CastingVariant::ExilePermission { source, .. } => *source, + _ => return None, + }; + let source_obj = state.objects.get(&source)?; + active_static_definitions(state, source_obj) + .find_map(permission_granted_replacement) + .or_else(|| { + source_obj + .static_definitions + .iter_all() + .find_map(permission_granted_replacement) + }) +} + +// CR 305.1 + CR 614.1a + CR 607.1: Land path — read the granted leave-the- +// battlefield-exile replacement rider from the graveyard-play permission on the +// `source` permanent (The Eighth Doctor). A played land never self-grants this +// permission, so the spell-only self-granting fallback is omitted; routing +// through the shared `permission_granted_replacement` extractor keeps rider +// reading single-authority. +pub(super) fn graveyard_permission_granted_replacement( + state: &GameState, + source_id: ObjectId, +) -> Option> { + let source_obj = state.objects.get(&source_id)?; + active_static_definitions(state, source_obj).find_map(permission_granted_replacement) +} + // CR 205.1b + CR 613.1d: read the enters-with type-grant rider ("… is a [type] // in addition to its other types") from the *consumed* cast-this-way permission // only (the one supporting THIS cast), not any permission carrying modifications, diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index b4e5d5f322..2a0654f8d4 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -6983,6 +6983,20 @@ fn finalize_cast_with_phyrexian_choices_inner( .push((object_id, counter_type, 1)); } + // CR 614.1a + CR 603.6c + CR 607.1: install the granted "if this permanent + // would leave the battlefield, exile it instead" rider from the consumed + // permission (The Eighth Doctor). Queued now (spell on the stack); applied + // when the permanent enters the battlefield (shared `pending_etb_replacements` + // drain in `zone_pipeline::apply_zone_delivery_tail`). Read from the SELECTED + // permission (CR 608.2c) so a sibling permission's rider cannot leak. + let static_perm_granted_replacement = + super::casting::selected_static_permission_granted_replacement(state, &casting_variant); + if let Some(replacement) = static_perm_granted_replacement { + state + .pending_etb_replacements + .push((object_id, replacement)); + } + // CR 205.1b + CR 613.1d: A `CastFromZone` grant whose rider was "… is a // [type] in addition to its other types" (The Tomb of Aclazotz) records the // additive type-changing modifications on the granted `ExileWithAltCost`. diff --git a/crates/engine/src/game/effects/add_target_replacement.rs b/crates/engine/src/game/effects/add_target_replacement.rs index bb6f2fe00f..433c062457 100644 --- a/crates/engine/src/game/effects/add_target_replacement.rs +++ b/crates/engine/src/game/effects/add_target_replacement.rs @@ -211,13 +211,32 @@ pub fn resolve( // layer reset (evaluate_layers rebuilds live // replacement_definitions from base — layers.rs). The base // store is otherwise the printed baseline (CR 613.1, - // game_object.rs); this is a deliberate, prune-bounded - // exception: the three lapse prunes (control swap, source - // leave-play, host leave-play) remove this def on every - // CR 611.2b lapse, so base never accumulates a stale runtime - // rider. printed_cards.rs is the only intrinsic base-write - // precedent; there is no additive-runtime base-push - // precedent, so this exception is documented here. This + // game_object.rs). Three additive base-write sites bear on + // this invariant (two production, one test-only), documented + // together so this note does not drift: + // (1) THIS `ControllerControlsSource`-gated push — the only + // *prune-bounded* additive base-write: its three CR 611.2b + // lapse prunes (control swap, source leave-play, host + // leave-play) remove this def on every lapse, so base never + // accumulates a stale runtime rider. + // (2) The granted-ETB-replacement drain + // (`zone_pipeline::apply_zone_delivery_tail`, The Eighth + // Doctor) — an *ungated* base-write (`condition: None`, no + // lapse prune) that relies for cleanup solely on "a + // battlefield exit mints a new object whose base is rebuilt + // from the printed card"; correct because that granted + // replacement is itself self-exiling. + // (3) `morph.rs` (test scaffolding only, under `#[cfg(test)]`) + // manually base+live-pushes a `TurnFaceUp` replacement to + // exercise the base→live layer-reset survival path + // (CR 614.1e + CR 708.11 — "As [this] is turned face up"). + // It is NOT a production runtime rider; it has NOT been + // migrated and remains a raw + // `Arc::make_mut(&mut base_replacement_definitions).push`. + // Only production sites (1) and (2) route through + // `GameObject::install_replacement` — its single authority is + // scoped to exactly those two callers (see its doc); the + // morph.rs test push stays a bare manual write. This // gate-scoping keeps transient riders (die-exile, Crafty // Cutpurse, end-of-turn) live-only and untouched. // @@ -234,11 +253,7 @@ pub fn resolve( Some(ReplacementCondition::ControllerControlsSource { .. }) ); if let Some(obj) = state.objects.get_mut(&obj_id) { - if install_to_base { - std::sync::Arc::make_mut(&mut obj.base_replacement_definitions) - .push(replacement.clone()); - } - obj.replacement_definitions.push(replacement); + obj.install_replacement(replacement, install_to_base); attached += 1; } } diff --git a/crates/engine/src/game/effects/create_emblem.rs b/crates/engine/src/game/effects/create_emblem.rs index e5a4394513..16cf9df4c5 100644 --- a/crates/engine/src/game/effects/create_emblem.rs +++ b/crates/engine/src/game/effects/create_emblem.rs @@ -159,6 +159,7 @@ mod tests { graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, }) .affected(TargetFilter::Typed(TypedFilter::new( crate::types::ability::TypeFilter::Land, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 08c88d411d..154628d61e 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -7616,6 +7616,24 @@ fn handle_play_land( } } + // CR 305.1 + CR 614.1a + CR 603.6c: a land played from the graveyard via a + // once-per-turn permission inherits that permission's granted "if this + // permanent would leave the battlefield, exile it instead" rider, installed + // as it enters the battlefield (same `pending_etb_replacements` queue and + // `apply_zone_delivery_tail` drain as the spell path). The land keeps its + // storage ObjectId across the graveyard→battlefield move, so keying the queue + // on `object_id` at play time drains against the same id at entry. Net-new + // path: no counter rider populates this queue for a land today. + if let Some(source) = gy_permission_source { + if let Some(replacement) = + super::casting::graveyard_permission_granted_replacement(state, source) + { + state + .pending_etb_replacements + .push((object_id, replacement)); + } + } + match super::replacement::replace_event(state, proposed, events) { super::replacement::ReplacementResult::Execute(event) => { if let crate::types::proposed_event::ProposedEvent::ZoneChange { object_id, .. } = event diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index 5e3d7d9fad..7bf84e73ae 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -9202,6 +9202,7 @@ fn grant_graveyard_creature_cast_and_bury( graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, }) .affected(TargetFilter::Typed( TypedFilter::creature().controller(ControllerRef::You), diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index f10fc1be7d..b11b3616b5 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -1191,6 +1191,26 @@ pub(crate) fn chosen_card_type_of(attrs: &[ChosenAttribute]) -> Option } impl GameObject { + /// CR 613.1 + CR 614.1a: install a replacement definition onto this object. + /// `to_base = true` also writes it to the printed-characteristics base store + /// (`base_replacement_definitions`) so it survives every layer reset — the + /// layer pass rebuilds the live store from base (see the + /// `replacement_definitions = base_replacement_definitions` reset below in + /// this file); the live store is always updated so the def functions this + /// pass. Single authority for additive replacement installs (shared by + /// `effects::add_target_replacement` and the granted-ETB-replacement drain in + /// `zone_pipeline::apply_zone_delivery_tail`). + pub(crate) fn install_replacement( + &mut self, + def: crate::types::ability::ReplacementDefinition, + to_base: bool, + ) { + if to_base { + std::sync::Arc::make_mut(&mut self.base_replacement_definitions).push(def.clone()); + } + self.replacement_definitions.push(def); + } + /// Apply an Alchemy "perpetually" modification to this card: record it on the /// object (so it persists across zones/serialization and can be re-applied /// after a copy rebuilds base characteristics) and edit the corresponding diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index dcee516102..5a267a8f83 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -984,6 +984,10 @@ pub fn start_next_turn(state: &mut GameState, events: &mut Vec) { state.pending_next_spell_modifiers.clear(); // CR 614.1c: Pending ETB counters are turn-scoped (e.g., "this turn" effects). state.pending_etb_counters.clear(); + // CR 614.1a: Backstop-clear pending granted ETB replacements for spells that + // never resolved this turn (e.g. countered). The play→enter path is same-turn, + // so this never drops a live install. + state.pending_etb_replacements.clear(); state.modal_modes_chosen_this_turn.clear(); for player in &mut state.players { player.has_drawn_this_turn = false; diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 4e4065aff9..81dcc7b2d2 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -1237,6 +1237,37 @@ pub(crate) fn apply_zone_delivery_tail( library_placement: Option<&LibraryPosition>, events: &mut Vec, ) -> ZoneDeliveryResult { + // CR 614.1a + CR 613.1: install queued granted replacements onto the entering + // permanent (The Eighth Doctor's "if this permanent would leave the + // battlefield, exile it instead"). This is the single post-counter + // convergence point reached by BOTH the immediate `deliver_replaced_zone_change` + // fall-through and the counter-pause resume (`ContinueZoneDeliveryTail`), so a + // single drain here fires exactly once per delivery and cannot double-install. + // Persistent granted riders MUST go to base (`to_base = true`): the layer pass + // rebuilds live `replacement_definitions` from base every pass + // (game_object.rs). Cleanup is by object-mint on battlefield exit (a new object + // rebuilds base from the printed card), so base never accumulates a stale + // rider — this rider is itself self-exiling, so there is no lapse prune and + // none is needed (CR 611.2c: the granted ability persists on the object + // independent of the granting source). + if to == Zone::Battlefield { + let installs: Vec<_> = state + .pending_etb_replacements + .iter() + .filter(|(oid, _)| *oid == object_id) + .map(|(_, r)| r.clone()) + .collect(); + if !installs.is_empty() { + state + .pending_etb_replacements + .retain(|(oid, _)| *oid != object_id); + if let Some(obj) = state.objects.get_mut(&object_id) { + for replacement in installs { + obj.install_replacement(*replacement, true); + } + } + } + } // CR 701.24a: To shuffle a library, randomize the cards within it so that // no player knows their order. A request that places the object at a specific // position is NOT a shuffle (a placement instruction is not a shuffle diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 240b85bb51..2a4b1dbc96 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -1669,6 +1669,11 @@ fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> { value( (), alt(( + // `~` is the normalized self-reference token ("this permanent" → + // `~`); accepting it alongside the un-normalized phrase forms lets the + // shared def-builder parse a granted rider whether or not the caller + // normalized the host reference. + tag("~"), tag("it"), tag("the card"), tag("the creature"), @@ -1686,11 +1691,17 @@ fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> { .parse(input) } -/// CR 614.1a + CR 614.6: Detect "If it would leave the battlefield, exile it -/// instead of putting it anywhere else." riders attached to a previously -/// selected object. The carried `ReplacementDefinition` is installed on the -/// parent target, where `valid_card: SelfRef` binds to that host object. -fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Option { +/// CR 614.1a + CR 614.6 + CR 603.6c: Build the "If ‹this permanent› would leave +/// the battlefield, exile it instead of putting it anywhere else." +/// `ReplacementDefinition` from its (lowercased) inner text. The produced def is +/// a `Moved`/`SelfRef` battlefield-exit → Exile redirect (Rest-in-Peace class), +/// where `valid_card: SelfRef` binds to the host object the def is installed on. +/// Shared builder: used by the effect-rider wrapper +/// (`try_parse_leave_battlefield_exile_replacement`) AND by the granted-permission +/// carrier (`oracle_static::restriction::try_parse_disjunctive_graveyard_cast_permission`). +pub(crate) fn parse_leave_battlefield_exile_replacement_def( + lower: &str, +) -> Option { let (rest, _) = nom::combinator::opt(tag::<_, _, OracleError<'_>>("if ")) .parse(lower) .ok()?; @@ -1709,27 +1720,36 @@ fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Option .ok()?; parse_optional_period_and_end(rest)?; - let replacement = ReplacementDefinition::new(ReplacementEvent::Moved) - .valid_card(TargetFilter::SelfRef) - .execute(AbilityDefinition::new( - AbilityKind::Spell, - Effect::ChangeZone { - origin: Some(Zone::Battlefield), - destination: Zone::Exile, - target: TargetFilter::SelfRef, - owner_library: false, - enter_transformed: false, - enters_under: None, - enter_tapped: crate::types::zones::EtbTapState::Unspecified, - enters_attacking: false, - up_to: false, - enter_with_counters: vec![], - conditional_enter_with_counters: vec![], - face_down_profile: None, - enters_modified_if: None, - }, - )); + Some( + ReplacementDefinition::new(ReplacementEvent::Moved) + .valid_card(TargetFilter::SelfRef) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: Some(Zone::Battlefield), + destination: Zone::Exile, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + )), + ) +} +/// CR 614.1a + CR 614.6: Detect "If it would leave the battlefield, exile it +/// instead of putting it anywhere else." riders attached to a previously +/// selected object. The carried `ReplacementDefinition` is installed on the +/// parent target, where `valid_card: SelfRef` binds to that host object. +fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Option { + let replacement = parse_leave_battlefield_exile_replacement_def(lower)?; Some(Effect::AddTargetReplacement { replacement: Box::new(replacement), target: TargetFilter::Any, diff --git a/crates/engine/src/parser/oracle_static/restriction.rs b/crates/engine/src/parser/oracle_static/restriction.rs index c7e4c6231e..d7509bf3c0 100644 --- a/crates/engine/src/parser/oracle_static/restriction.rs +++ b/crates/engine/src/parser/oracle_static/restriction.rs @@ -1622,6 +1622,7 @@ pub(crate) fn try_parse_graveyard_cast_permission( graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, }) .affected(affected) .description(text.to_string()), @@ -1745,6 +1746,9 @@ pub(crate) fn try_parse_graveyard_cast_permission( graveyard_destination_replacement, extra_cost, enters_with_counter, + // CR 614.1a: This non-disjunctive builder handles no granted-replacement + // rider today (the disjunctive Eighth Doctor form does); default to None. + granted_replacement: None, }) .affected(affected) .description(text.to_string()); @@ -1880,10 +1884,13 @@ fn inject_owner_you_filter_prop(filter: TargetFilter) -> Option { /// filter (The Eighth Doctor: both "historic permanent"), the union collapses /// to that single filter; otherwise it emits `TargetFilter::Or`. /// -/// A trailing rider ("If you do, it gains \"…\"") is intentionally rejected: -/// the granted leave-battlefield exile rider is a CR 614.1a Moved replacement -/// on the resolved permanent. Parsing only the permission would make coverage -/// report support while dropping rules text. +/// A trailing granted-replacement rider ("... . If you do, it gains \"…\".") is +/// now parsed into `granted_replacement` (see below): the granted +/// leave-battlefield exile rider is a CR 614.1a "instead" (Moved) replacement +/// installed on the permanent played this way at the battlefield-entry seam. A +/// rider tail that is *present but unparsable* aborts the whole permission +/// rather than emitting a permission with a silently-dropped rider — reporting +/// coverage while losing rules text would be a dishonest gap. fn try_parse_disjunctive_graveyard_cast_permission( text: &str, lower: &str, @@ -1897,14 +1904,37 @@ fn try_parse_disjunctive_graveyard_cast_permission( "once during each of your turns, you may play ", ) .or_else(|| nom_tag_lower(lower, lower, "once each turn, you may play "))?; - if nom_primitives::scan_contains(rest, "if you do, it gains") { - return None; - } + + // CR 614.1a + CR 607.1: Split off the optional granted-replacement rider + // tail — "... . If you do, it gains \"\"." — before the branch + // logic. `split_once_on` is the structural "everything up to delimiter" + // combinator. Absent (`Err`) → no rider (Serra Paragon / Muldrotha forms + // unaffected). Present → strip the surrounding quotes with a nom `delimited` + // combinator and parse the inner text via the shared leave-battlefield + // builder; a rider tail present but unparsable aborts the whole permission + // (never emit a permission with a silently-dropped rider — honest gap). + let (permission_clause, granted_replacement) = + match nom_primitives::split_once_on(rest, ". if you do, it gains ") { + Ok((_, (before, after))) => { + use nom::character::complete::char; + use nom::sequence::delimited; + let (_, inner) = + delimited(char::<_, OracleError<'_>>('"'), take_until("\""), char('"')) + .parse(after.trim()) + .ok()?; + let def = + crate::parser::oracle_effect::parse_leave_battlefield_exile_replacement_def( + inner, + )?; + (before, Some(Box::new(def))) + } + Err(_) => (rest, None), + }; // CR 305.1 + CR 601.2a: The disjunction connector " or cast " splits the // land-play branch from the spell-cast branch. `split_once_on` is the // structural "everything up to delimiter" combinator. - let (land_branch, spell_branch) = nom_primitives::split_once_on(rest, " or cast ") + let (land_branch, spell_branch) = nom_primitives::split_once_on(permission_clause, " or cast ") .ok() .map(|(_, pair)| pair)?; @@ -1941,6 +1971,10 @@ fn try_parse_disjunctive_graveyard_cast_permission( graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + // CR 614.1a: The Eighth Doctor's granted "if this permanent would + // leave the battlefield, exile it instead" rider, installed on the + // permanent played this way at the battlefield-entry seam. + granted_replacement, }) .affected(affected) .description(text.to_string()), @@ -1982,6 +2016,7 @@ fn try_parse_unlimited_combined_graveyard_permission( graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, }) .affected(affected) .description(text.to_string()), diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 0d4948dd9f..5801d3b8b5 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -12867,13 +12867,94 @@ fn graveyard_cast_permission_muldrotha_legacy_and() { )); } -#[test] -fn graveyard_cast_permission_disjunctive_rejects_unmodeled_granted_rider() { +/// CR 305.1 + CR 601.2a + CR 614.1a: The Eighth Doctor's disjunctive once-per-turn +/// permission carries the granted leave-the-battlefield-exile replacement rider +/// ("If you do, it gains \"If ~ would leave the battlefield, exile it instead of +/// putting it anywhere else.\""). The whole line lowers to a +/// `GraveyardCastPermission { granted_replacement: Some(..) }` whose boxed def is +/// a `Moved`/`SelfRef` battlefield-exit → Exile redirect. Tests the +/// granted-replacement CARRIER class, not the Doctor string. +#[test] +fn graveyard_cast_permission_disjunctive_parses_granted_leave_battlefield_rider() { let text = "Once during each of your turns, you may play a historic land or cast a historic permanent spell from your graveyard. If you do, it gains \"If ~ would leave the battlefield, exile it instead of putting it anywhere else.\""; + let def = parse_static_line(text).expect("granted leave-battlefield rider must now parse"); + let StaticMode::GraveyardCastPermission { + frequency: CastFrequency::OncePerTurn, + play_mode: CardPlayMode::Play, + granted_replacement: Some(repl), + .. + } = &def.mode + else { + panic!( + "expected OncePerTurn Play permission with a granted replacement, got {:?}", + def.mode + ); + }; + assert_eq!( + repl.event, + crate::types::replacements::ReplacementEvent::Moved + ); + assert_eq!(repl.valid_card, Some(TargetFilter::SelfRef)); + let execute = repl + .execute + .as_ref() + .expect("granted rider must carry an execute effect"); assert!( - parse_static_line(text).is_none(), - "unmodeled granted leave-battlefield replacement must remain an honest coverage gap" + matches!( + execute.effect.as_ref(), + Effect::ChangeZone { + origin: Some(Zone::Battlefield), + destination: Zone::Exile, + target: TargetFilter::SelfRef, + .. + } + ), + "expected battlefield→exile SelfRef redirect, got {:?}", + execute.effect ); + // The permission's affected set is unchanged: Or[historic land, historic + // permanent]. + let filter = def.affected.as_ref().expect("should have affected filter"); + assert!( + matches!(filter, TargetFilter::Or { filters } if filters.len() == 2), + "expected Or over both historic branches, got {filter:?}" + ); +} + +/// Building-block test: `parse_leave_battlefield_exile_replacement_def` lowers the +/// normalized inner rider text (self-ref `~`) to the shared `Moved`/`SelfRef` → +/// Exile redirect, and still accepts the un-normalized pronoun form (proves the +/// `~` alt arm didn't regress the "it" path). Exercises the shared builder +/// directly, not one card's Oracle string. +#[test] +fn leave_battlefield_exile_replacement_def_builds_moved_selfref_redirect() { + for inner in [ + "if ~ would leave the battlefield, exile it instead of putting it anywhere else", + "if it would leave the battlefield, exile it instead of putting it anywhere else", + ] { + let def = + crate::parser::oracle_effect::parse_leave_battlefield_exile_replacement_def(inner) + .unwrap_or_else(|| panic!("should parse leave-battlefield rider: {inner:?}")); + assert_eq!( + def.event, + crate::types::replacements::ReplacementEvent::Moved + ); + assert_eq!(def.valid_card, Some(TargetFilter::SelfRef)); + let execute = def.execute.as_ref().expect("execute effect"); + assert!( + matches!( + execute.effect.as_ref(), + Effect::ChangeZone { + origin: Some(Zone::Battlefield), + destination: Zone::Exile, + target: TargetFilter::SelfRef, + .. + } + ), + "got {:?}", + execute.effect + ); + } } /// CR 305.1 + CR 601.2a + CR 700.6: Tail-zone disjunctive permission — diff --git a/crates/engine/src/parser/swallow_check.rs b/crates/engine/src/parser/swallow_check.rs index 251f621b3a..b94ddae57f 100644 --- a/crates/engine/src/parser/swallow_check.rs +++ b/crates/engine/src/parser/swallow_check.rs @@ -1483,6 +1483,15 @@ fn static_is_replacement_carrier(static_def: &StaticDefinition) -> bool { graveyard_destination_replacement: Some(_), .. } + // CR 614.1a + CR 603.6c: "If you do, it gains \"If this permanent would + // leave the battlefield, exile it instead of putting it anywhere else.\"" + // (The Eighth Doctor). The granted per-object leave-the-battlefield → exile + // redirect IS the replaced event; `Some(_)` is the rider (`None` keeps + // warning). Installed at battlefield entry via `pending_etb_replacements`. + | StaticMode::GraveyardCastPermission { + granted_replacement: Some(_), + .. + } // CR 614.1a + CR 701.23: "If an opponent would search a library, that player // searches the top four cards of that library instead" (aven mindcensor) — the // replaced search IS this mode. @@ -2948,6 +2957,48 @@ fn cast_this_way_alt_cost_is_only_if_marker(stripped: &str, evidence: &UnitEvide !has_other_if } +/// CR 614.1a + CR 607.1 + CR 603.6c: a `GraveyardCastPermission` carrying a +/// `granted_replacement` rider ("If you do, it gains \"If this permanent would +/// leave the battlefield, exile it instead of putting it anywhere else.\"" — The +/// Eighth Doctor) represents that entire "if you do, it gains … instead …" +/// sentence structurally (the granted leave-the-battlefield → exile +/// replacement). Suppress only that represented sentence; any other independent +/// conditional in the same item must remain visible to the audit. +fn granted_leave_battlefield_rider_is_only_if_marker( + stripped: &str, + evidence: &UnitEvidence, +) -> bool { + if !evidence.any_static_mode(|mode| { + matches!( + mode, + StaticMode::GraveyardCastPermission { + granted_replacement: Some(_), + .. + } + ) + }) { + return false; + } + + let residual: String = stripped + .split('.') + .filter(|sentence| { + let sentence = sentence.trim_start(); + // The represented rider sentence is the one that both grants ("it + // gains") and describes the leave-the-battlefield redirect; drop only + // that one. + let is_rider = sentence.contains("it gains") // allow-noncombinator: swallow detector marker scan on classified text + && sentence.contains("leave the battlefield"); // allow-noncombinator: swallow detector marker scan on classified text + !is_rider + }) + .collect::>() + .join("."); + let has_other_if = residual.contains(" if ") // allow-noncombinator: swallow detector marker scan on classified text + && !residual.contains(" as if ") // allow-noncombinator: swallow detector marker scan on classified text + && !residual.contains(" even if "); // allow-noncombinator: swallow detector marker scan on classified text + !has_other_if +} + // ── Detector G: Condition_If ──────────────────────────────────────────── /// CR 608.2c: "if [condition], [effect]" — conditional gate. Must be @@ -3060,6 +3111,13 @@ fn detect_condition_if( if cast_this_way_alt_cost_is_only_if_marker(&stripped, evidence) { return; } + // CR 614.1a + CR 607.1 + CR 603.6c: "If you do, it gains \"If this permanent + // would leave the battlefield, exile it instead …\"" (The Eighth Doctor) is + // represented by the permission's `granted_replacement` rider, not a swallowed + // condition. + if granted_leave_battlefield_rider_is_only_if_marker(&stripped, evidence) { + return; + } // CR 615.5: "If damage is prevented this way, [effect]" is not an // independent condition; prevention replacements encode it by storing the // follow-up in `execute`, which the replacement pipeline only fires from @@ -5455,6 +5513,45 @@ mod tests { assert!(!has_swallowed_detector(&parsed, "Condition_If")); } + /// CR 614.1a + CR 607.1 + CR 603.6c: The Eighth Doctor's granted + /// leave-the-battlefield → exile rider on its `GraveyardCastPermission` is a + /// structurally represented replacement carrier, so the whole "If you do, it + /// gains \"If this permanent would leave the battlefield, exile it instead of + /// putting it anywhere else.\"" sentence must trip NEITHER the + /// `Replacement_Instead` nor the `Condition_If` swallow detector. Guards the + /// carrier-class suppression (reverting either exemption flips one assertion). + #[test] + fn eighth_doctor_granted_leave_battlefield_rider_is_not_swallowed() { + let parsed = parse_named( + "When The Eighth Doctor enters, mill three cards.\n\ + Once during each of your turns, you may play a historic land or cast a historic permanent spell from your graveyard. \ + If you do, it gains \"If this permanent would leave the battlefield, exile it instead of putting it anywhere else.\"", + "The Eighth Doctor", + &["Creature"], + ); + assert!( + !has_swallowed_detector(&parsed, "Replacement_Instead"), + "granted leave-battlefield rider must not report Replacement_Instead: {:?}", + parsed.parse_warnings + ); + assert!( + !has_swallowed_detector(&parsed, "Condition_If"), + "granted leave-battlefield rider must not report Condition_If: {:?}", + parsed.parse_warnings + ); + assert!( + parsed.statics.iter().any(|s| matches!( + s.mode, + StaticMode::GraveyardCastPermission { + granted_replacement: Some(_), + .. + } + )), + "expected a GraveyardCastPermission carrying granted_replacement, got {:?}", + parsed.statics + ); + } + /// CR 509.1c: "must be blocked if able" riders are represented by /// `StaticMode::MustBeBlocked { by }`, so the trailing "if able" must not be /// reported as a swallowed `Condition_If`. The `by` field has no diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 3b1eeb8138..5af0e23032 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -7735,6 +7735,17 @@ pub struct GameState { /// Consumed when the object enters the battlefield. Each entry: (object_id, counter_type, count). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pending_etb_counters: Vec<(ObjectId, CounterType, u32)>, + /// CR 614.1a + CR 614.12: Pending granted leave-the-battlefield replacements + /// for objects that haven't entered the battlefield yet. Queued by a + /// play/cast-from-zone permission that grants a per-object replacement to the + /// object played this way (The Eighth Doctor). Installed onto the object's + /// `base_replacement_definitions` when it enters + /// (`zone_pipeline::apply_zone_delivery_tail`), then removed from this queue. + /// Same transient, turn-scoped lifetime class as `pending_etb_counters`. Each + /// entry: (object_id, granted replacement). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pending_etb_replacements: + Vec<(ObjectId, Box)>, /// Modal modes chosen this turn per source: (ObjectId, mode_index). /// CR 700.2: "choose one that hasn't been chosen this turn" @@ -9675,6 +9686,7 @@ impl GameState { pending_spell_cost_reductions: Vec::new(), pending_next_spell_modifiers: Vec::new(), pending_etb_counters: Vec::new(), + pending_etb_replacements: Vec::new(), modal_modes_chosen_this_turn: HashSet::new(), modal_modes_chosen_this_game: HashSet::new(), revealed_cards: HashSet::new(), @@ -10678,6 +10690,7 @@ fn _gamestate_partition_is_total(s: &GameState) { pending_spell_cost_reductions: _, pending_next_spell_modifiers: _, pending_etb_counters: _, + pending_etb_replacements: _, modal_modes_chosen_this_turn: _, modal_modes_chosen_this_game: _, revealed_cards: _, @@ -10974,6 +10987,7 @@ impl PartialEq for GameState { && self.pending_spell_cost_reductions == other.pending_spell_cost_reductions && self.pending_next_spell_modifiers == other.pending_next_spell_modifiers && self.pending_etb_counters == other.pending_etb_counters + && self.pending_etb_replacements == other.pending_etb_replacements && self.modal_modes_chosen_this_turn == other.modal_modes_chosen_this_turn && self.modal_modes_chosen_this_game == other.modal_modes_chosen_this_game && self.revealed_cards == other.revealed_cards diff --git a/crates/engine/src/types/statics.rs b/crates/engine/src/types/statics.rs index 4d2640067e..b477f05d3d 100644 --- a/crates/engine/src/types/statics.rs +++ b/crates/engine/src/types/statics.rs @@ -7,7 +7,7 @@ use strum::EnumCount; use super::ability::{ AbilityCost, CardPlayMode, CastTimingPermission, CostCategory, PlayerFilter, QuantityExpr, - QuantityRef, TargetFilter, + QuantityRef, ReplacementDefinition, TargetFilter, }; use super::identifiers::ObjectId; use super::keywords::{Keyword, KeywordKind}; @@ -1201,6 +1201,21 @@ pub enum StaticMode { /// `Effect::CastFromZone.enters_with_counter`. #[serde(default, skip_serializing_if = "Option::is_none")] enters_with_counter: Option, + /// CR 614.1a + CR 603.6c + CR 611.2c + CR 607.1: Optional granted + /// replacement installed on the permanent played/cast via this permission + /// — "If you do, it gains '‹replacement›'." Per CR 611.2c the granted + /// ability then persists on that object independent of this permission's + /// source (base-install achieves this — see + /// `zone_pipeline::apply_zone_delivery_tail`). The Eighth Doctor grants + /// "If this permanent would leave the battlefield, exile it instead of + /// putting it anywhere else." — a `Moved`/`SelfRef` battlefield-exit → + /// Exile redirect (CR 614.1a). Installed at the battlefield-entry seam via + /// `GameState.pending_etb_replacements`, mirroring `enters_with_counter`. + /// `None` (default) preserves every existing graveyard-cast shape. General + /// carrier: any granted per-object replacement. Preserved through serde + /// only (Display/FromStr default it to `None`, like `enters_with_counter`). + #[serde(default, skip_serializing_if = "Option::is_none")] + granted_replacement: Option>, }, /// CR 401.5 + CR 118.9 + CR 601.2a: Static ability granting permission to /// play/cast the top card of the controller's library when it matches @@ -3198,6 +3213,7 @@ impl FromStr for StaticMode { graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, }, s if s.starts_with("GraveyardCastPermission(") => { let inner = s @@ -3217,6 +3233,9 @@ impl FromStr for StaticMode { // to None. extra_cost: None, enters_with_counter: None, + // CR 614.1a: the granted-replacement payload rides on serde, + // not the Display/FromStr round-trip; default to None. + granted_replacement: None, } } else { StaticMode::GraveyardCastPermission { @@ -3225,6 +3244,7 @@ impl FromStr for StaticMode { graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, } } } @@ -4010,6 +4030,7 @@ mod tests { graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, }, StaticMode::GraveyardCastPermission { frequency: CastFrequency::Unlimited, @@ -4017,6 +4038,7 @@ mod tests { graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, }, // CR 601.2f: Festival of Embers — graveyard cast with an additional // pay-life cost. NOTE: `extra_cost`-bearing variants are NOT in this @@ -4203,6 +4225,7 @@ mod tests { mode: CastCostMode::Additional, }), enters_with_counter: None, + granted_replacement: None, }, StaticMode::ExileCastPermission { frequency: CastFrequency::Unlimited, diff --git a/crates/engine/tests/integration/eighth_doctor_granted_leave_battlefield_exile.rs b/crates/engine/tests/integration/eighth_doctor_granted_leave_battlefield_exile.rs new file mode 100644 index 0000000000..f140335cfa --- /dev/null +++ b/crates/engine/tests/integration/eighth_doctor_granted_leave_battlefield_exile.rs @@ -0,0 +1,227 @@ +//! Cluster-113 — The Eighth Doctor: play/cast-from-graveyard permission that +//! GRANTS a per-object leave-the-battlefield-exile replacement to the permanent +//! played this way. +//! +//! Oracle text (verbatim, Scryfall): +//! "When The Eighth Doctor enters, mill three cards. +//! Once during each of your turns, you may play a historic land or cast a +//! historic permanent spell from your graveyard. If you do, it gains \"If this +//! permanent would leave the battlefield, exile it instead of putting it +//! anywhere else.\"" +//! +//! Runtime proofs (the parse-level assertions live in +//! `parser/oracle_static/tests.rs`): +//! T1 — cast a historic permanent from the graveyard via the permission: it +//! enters the battlefield AND the granted `Moved`/`SelfRef` → Exile redirect +//! is installed on its `base_replacement_definitions`; a subsequent +//! battlefield exit (Destroy) lands it in EXILE, not the graveyard. This +//! proves (i) the `SelfRef Moved` install machinery fires for a def installed +//! directly onto a battlefield object, and (ii) `SelfRef` binds to the +//! ENTRANT, not the granting Doctor. +//! T2 — CR 611.2c persistence: after the grant, the Doctor leaves the +//! battlefield; the granted permanent's exit STILL redirects to exile (the +//! rider is independent of its source). +//! T3 — net-new land path: a historic land played from the graveyard via the +//! permission carries the rider on its `base_replacement_definitions`, and +//! its exit redirects to exile. + +use engine::game::casting::spell_objects_available_to_cast; +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::types::ability::{Effect, TargetFilter}; +use engine::types::actions::GameAction; +use engine::types::identifiers::ObjectId; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::replacements::ReplacementEvent; +use engine::types::zones::Zone; + +const EIGHTH_DOCTOR_PERMISSION: &str = "Once during each of your turns, you may play a historic land or cast a historic permanent spell from your graveyard. If you do, it gains \"If this permanent would leave the battlefield, exile it instead of putting it anywhere else.\""; +const DESTROY_CREATURE: &str = "Destroy target creature."; +const DESTROY_LAND: &str = "Destroy target land."; + +/// True when `obj`'s `base_replacement_definitions` carries the granted +/// leave-the-battlefield → exile redirect (`Moved` / `SelfRef` / `ChangeZone` +/// Battlefield→Exile). The rider MUST live on base so it survives every layer +/// reset (the runtime redirect is rebuilt from base each pass). +fn has_granted_leave_battlefield_exile(runner: &GameRunner, obj: ObjectId) -> bool { + runner.state().objects[&obj] + .base_replacement_definitions + .iter() + .any(|def| { + def.event == ReplacementEvent::Moved + && def.valid_card == Some(TargetFilter::SelfRef) + && def.execute.as_ref().is_some_and(|ability| { + matches!( + ability.effect.as_ref(), + Effect::ChangeZone { + origin: Some(Zone::Battlefield), + destination: Zone::Exile, + target: TargetFilter::SelfRef, + .. + } + ) + }) + }) +} + +/// Build a scenario with The Eighth Doctor's permission static on the battlefield +/// (P0), a zero-cost historic (legendary) permanent spell in P0's graveyard, and +/// a "Destroy target creature." spell in hand. Returns the runner plus the Doctor +/// and graveyard-creature ids. +fn doctor_with_graveyard_historic() -> (GameRunner, ObjectId, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain).with_life(P0, 20); + let doctor = scenario + .add_creature(P0, "The Eighth Doctor", 4, 5) + .from_oracle_text(EIGHTH_DOCTOR_PERMISSION) + .id(); + let historic = scenario + .add_creature_to_graveyard(P0, "Legendary Reveler", 2, 2) + .as_legendary() + .with_mana_cost(ManaCost::zero()) + .id(); + let murder = scenario + .add_spell_to_hand_from_oracle(P0, "Murder", true, DESTROY_CREATURE) + .with_mana_cost(ManaCost::zero()) + .id(); + let runner = scenario.build(); + (runner, doctor, historic, murder) +} + +/// T1 — the graveyard-cast permanent enters carrying the granted redirect on its +/// base store, and a battlefield exit redirects to EXILE (bound to the entrant, +/// not the Doctor). +#[test] +fn granted_rider_installs_and_redirects_battlefield_exit_to_exile() { + let (mut runner, _doctor, historic, murder) = doctor_with_graveyard_historic(); + + // reach-guard: the historic permanent is castable from the graveyard. + assert!( + spell_objects_available_to_cast(runner.state(), P0).contains(&historic), + "The Eighth Doctor must surface the historic graveyard permanent as castable" + ); + + // Cast it via the permission; it resolves onto the battlefield. + let outcome = runner.cast(historic).resolve(); + assert_eq!( + outcome.zone_of(historic), + Zone::Battlefield, + "the historic permanent cast via the permission must resolve onto the battlefield" + ); + + // DISCRIMINATING: the granted redirect is installed on the entrant's base. + assert!( + has_granted_leave_battlefield_exile(&runner, historic), + "the permanent played via the permission must carry the granted leave-battlefield→exile redirect on its base store" + ); + + // Drive a battlefield exit; the redirect sends it to EXILE, not the graveyard. + runner.cast(murder).target_object(historic).resolve(); + runner.advance_until_stack_empty(); + assert_eq!( + runner.state().objects[&historic].zone, + Zone::Exile, + "the granted rider must redirect the destroyed permanent to exile (SelfRef binds to the entrant)" + ); +} + +/// T2 — CR 611.2c: the granted rider persists after the granting Doctor leaves. +#[test] +fn granted_rider_persists_after_doctor_leaves() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain).with_life(P0, 20); + let doctor = scenario + .add_creature(P0, "The Eighth Doctor", 4, 5) + .from_oracle_text(EIGHTH_DOCTOR_PERMISSION) + .id(); + let historic = scenario + .add_creature_to_graveyard(P0, "Legendary Reveler", 2, 2) + .as_legendary() + .with_mana_cost(ManaCost::zero()) + .id(); + // Two destroy spells: one for the Doctor, one for the granted permanent. + let kill_doctor = scenario + .add_spell_to_hand_from_oracle(P0, "Doom Blade", true, DESTROY_CREATURE) + .with_mana_cost(ManaCost::zero()) + .id(); + let kill_grantee = scenario + .add_spell_to_hand_from_oracle(P0, "Murder", true, DESTROY_CREATURE) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + // Cast the historic permanent, then remove the Doctor from the battlefield. + runner.cast(historic).resolve(); + assert!( + has_granted_leave_battlefield_exile(&runner, historic), + "reach-guard: the rider must be installed before the Doctor leaves" + ); + runner.cast(kill_doctor).target_object(doctor).resolve(); + runner.advance_until_stack_empty(); + assert_ne!( + runner.state().objects[&doctor].zone, + Zone::Battlefield, + "reach-guard: the Doctor must have left the battlefield" + ); + + // The granted rider still redirects the permanent's exit to EXILE. + runner.cast(kill_grantee).target_object(historic).resolve(); + runner.advance_until_stack_empty(); + assert_eq!( + runner.state().objects[&historic].zone, + Zone::Exile, + "the granted rider persists independent of its source (CR 611.2c): the exit still redirects to exile" + ); +} + +/// T3 — net-new land path: a historic land played from the graveyard via the +/// permission carries the rider on its base store; its exit redirects to exile. +#[test] +fn granted_rider_installs_on_land_played_from_graveyard() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain).with_life(P0, 20); + scenario + .add_creature(P0, "The Eighth Doctor", 4, 5) + .from_oracle_text(EIGHTH_DOCTOR_PERMISSION); + // A historic (legendary) land in the graveyard. + let land = scenario + .add_creature_to_graveyard(P0, "Legendary Wastes", 0, 0) + .as_land() + .as_legendary() + .id(); + let raze = scenario + .add_spell_to_hand_from_oracle(P0, "Stone Rain", true, DESTROY_LAND) + .with_mana_cost(ManaCost::zero()) + .id(); + let mut runner = scenario.build(); + + // Play the land from the graveyard via the permission (land special action). + let card_id = runner.state().objects[&land].card_id; + runner + .act(GameAction::PlayLand { + object_id: land, + card_id, + }) + .expect("historic land must be playable from the graveyard via the permission"); + assert_eq!( + runner.state().objects[&land].zone, + Zone::Battlefield, + "playing the historic land must move it to the battlefield" + ); + + // DISCRIMINATING (net-new land branch): the rider is installed on the land's + // base store — no counter rider populates this queue for a land today. + assert!( + has_granted_leave_battlefield_exile(&runner, land), + "the land played via the permission must carry the granted leave-battlefield→exile redirect on its base store" + ); + + // Its exit redirects to EXILE. + runner.cast(raze).target_object(land).resolve(); + runner.advance_until_stack_empty(); + assert_eq!( + runner.state().objects[&land].zone, + Zone::Exile, + "the granted rider must redirect the destroyed land to exile" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index ce46bf6e71..347b904e4e 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -100,6 +100,7 @@ mod dream_salvage_target_opponent_discards; mod dredgers_insight_mill_from_among; mod druid_of_purification_destroy_chosen_4780; mod duskmantle_seer_each_player_reveal; +mod eighth_doctor_granted_leave_battlefield_exile; mod elemental_spectacle_regression; mod elusive_otter_repro; mod embiggen_typeline_pump; diff --git a/crates/engine/tests/integration/rules/casting.rs b/crates/engine/tests/integration/rules/casting.rs index 0b989dd554..8d314df0db 100644 --- a/crates/engine/tests/integration/rules/casting.rs +++ b/crates/engine/tests/integration/rules/casting.rs @@ -1281,6 +1281,7 @@ fn play_land_from_graveyard_with_permission() { graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, }) .affected(TargetFilter::Typed( engine::types::ability::TypedFilter::new(TypeFilter::Land), @@ -1349,6 +1350,7 @@ fn play_land_from_graveyard_respects_land_drop_limit() { graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, }) .affected(TargetFilter::Typed( engine::types::ability::TypedFilter::new(TypeFilter::Land), @@ -1418,6 +1420,7 @@ fn muldrotha_per_permanent_type_blocks_second_land_from_graveyard() { graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, }) .affected(TargetFilter::Typed( engine::types::ability::TypedFilter::new(TypeFilter::Permanent), @@ -1498,6 +1501,7 @@ fn muldrotha_per_permanent_type_resets_at_turn_start() { graveyard_destination_replacement: None, extra_cost: None, enters_with_counter: None, + granted_replacement: None, }) .affected(TargetFilter::Typed( engine::types::ability::TypedFilter::new(TypeFilter::Permanent),