diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index d7fd6f316d..392265fcc5 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -20671,6 +20671,85 @@ pub mod tests { ); } + #[test] + fn dynamically_granted_replicate_enqueues_copy_trigger() { + // Issue #5323 (Hatchery Sliver: "Each Sliver spell you cast has replicate. + // The replicate cost is equal to its mana cost."). CR 702.56a + CR 611.2f: + // a Sliver spell with NO printed Replicate, cast while a + // `CastWithKeyword { Replicate(SelfManaCost) }` static grants it Replicate + // and one replicate instance was paid, must get a synthesized Replicate + // copy trigger from the `dynamically_granted_replicate` seam. This proves + // the parser grant (keyword_grant.rs) functions end-to-end with no engine + // change — the same path #5436 shipped for granted Blitz. + use crate::types::mana::ManaCost; + let mut state = setup(); + let caster = PlayerId(0); + + // Battlefield grantor: "Each Sliver spell you cast has replicate." + let grantor = create_object( + &mut state, + CardId(1), + caster, + "Hatchery Sliver".to_string(), + Zone::Battlefield, + ); + let grant = StaticDefinition::new(StaticMode::CastWithKeyword { + keyword: Keyword::Replicate(ManaCost::SelfManaCost), + }) + .affected(TargetFilter::Typed( + TypedFilter::new(TypeFilter::Subtype("Sliver".into())).controller(ControllerRef::You), + )); + state + .objects + .get_mut(&grantor) + .unwrap() + .static_definitions + .push(grant); + + // A Sliver spell with NO printed Replicate, one granted replicate instance paid. + let spell = create_object( + &mut state, + CardId(2), + caster, + "Cheap Sliver".to_string(), + Zone::Stack, + ); + { + let obj = state.objects.get_mut(&spell).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.card_types.subtypes.push("Sliver".into()); + obj.cast_from_zone = Some(Zone::Hand); + // CR 702.56b: one granted replicate instance (ordinal 0, since there + // are no printed instances) was paid once. + obj.additional_cost_payments.push( + crate::types::ability::AdditionalCostInstancePayment { + origin: AdditionalCostOrigin::Replicate, + origin_ordinal: 0, + count: 1, + }, + ); + } + + process_triggers( + &mut state, + &[GameEvent::SpellCast { + object_id: spell, + controller: caster, + card_id: CardId(2), + }], + ); + + assert!( + state.stack.iter().any(|entry| matches!( + &entry.kind, + StackEntryKind::TriggeredAbility { ability, .. } + if matches!(ability.effect, Effect::CopySpell { .. }) + )), + "dynamically granted Replicate should enqueue a copy trigger, stack: {:?}", + state.stack.iter().map(|e| &e.kind).collect::>() + ); + } + fn count_demonstrate_triggers(state: &GameState) -> usize { state .stack diff --git a/crates/engine/src/parser/oracle_static/keyword_grant.rs b/crates/engine/src/parser/oracle_static/keyword_grant.rs index 709c908ec9..bfff85118b 100644 --- a/crates/engine/src/parser/oracle_static/keyword_grant.rs +++ b/crates/engine/src/parser/oracle_static/keyword_grant.rs @@ -234,27 +234,38 @@ pub(crate) fn parse_spells_have_keyword_for_test(text: &str) -> Option Option { - [("blitz", Keyword::Blitz as fn(ManaCost) -> Keyword)] - .into_iter() - .find_map(|(name, ctor)| { - let parsed: OracleResult<'_, ()> = (|| { - let (i, _) = tag::<_, _, OracleError<'_>>(name).parse(keyword_str)?; - let (i, _) = tag(". the ").parse(i)?; - let (i, _) = tag(name).parse(i)?; - let (i, _) = tag(" cost is equal to ").parse(i)?; - let (i, _) = alt((tag("its"), tag("that card's"), tag("the card's"))).parse(i)?; - let (i, _) = tag(" mana cost").parse(i)?; - Ok((i, ())) - })(); - let (rest, ()) = parsed.ok()?; - rest.trim() - .trim_end_matches('.') - .trim() - .is_empty() - .then(|| ctor(ManaCost::SelfManaCost)) - }) + [ + ("blitz", Keyword::Blitz as fn(ManaCost) -> Keyword), + ("replicate", Keyword::Replicate as fn(ManaCost) -> Keyword), + ] + .into_iter() + .find_map(|(name, ctor)| { + let parsed: OracleResult<'_, ()> = (|| { + let (i, _) = tag::<_, _, OracleError<'_>>(name).parse(keyword_str)?; + let (i, _) = tag(". the ").parse(i)?; + let (i, _) = tag(name).parse(i)?; + let (i, _) = tag(" cost is equal to ").parse(i)?; + let (i, _) = alt((tag("its"), tag("that card's"), tag("the card's"))).parse(i)?; + let (i, _) = tag(" mana cost").parse(i)?; + Ok((i, ())) + })(); + let (rest, ()) = parsed.ok()?; + rest.trim() + .trim_end_matches('.') + .trim() + .is_empty() + .then(|| ctor(ManaCost::SelfManaCost)) + }) } /// Parse "[Type] spells you cast [from zone] have [keyword]" patterns. diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index fc667fe737..58249b6f44 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -10497,6 +10497,42 @@ fn static_grant_blitz_simple_form() { ); } +#[test] +fn static_grant_replicate_hatchery_sliver() { + // Issue #5323 — Hatchery Sliver: "Each Sliver spell you cast has replicate. + // The replicate cost is equal to its mana cost." lowers to a single + // `CastWithKeyword` granting `Replicate(SelfManaCost)` to the controller's + // Sliver spells (CR 702.56a). The self-referential cost resolves to each + // spell's own mana cost at cast time (same template as the granted-Blitz + // path), and the granted replicate functions end-to-end: the cost is read by + // `effective_replicate_additional_cost_instances` and the copy trigger by the + // `dynamically_granted_replicate` seam in `game/triggers.rs`. + use crate::types::mana::ManaCost; + let def = parse_static_line( + "Each Sliver spell you cast has replicate. The replicate cost is equal to its mana cost.", + ) + .expect("Hatchery Sliver granted-replicate static must parse"); + assert_eq!( + def.mode, + StaticMode::CastWithKeyword { + keyword: Keyword::Replicate(ManaCost::SelfManaCost), + } + ); + let Some(TargetFilter::Typed(tf)) = &def.affected else { + panic!( + "affected must be a Typed Sliver filter, got {:?}", + def.affected + ); + }; + assert_eq!(tf.controller, Some(ControllerRef::You)); + assert_eq!( + tf.get_subtype(), + Some("Sliver"), + "must scope to Sliver spells, got {:?}", + tf.type_filters + ); +} + #[test] fn static_cast_as_though_flash_all_spells() { // CR 601.3b: the bare "spells" form (Leyline of Anticipation, Vedalken