Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions crates/engine/src/game/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>()
);
}

fn count_demonstrate_triggers(state: &GameState) -> usize {
state
.stack
Expand Down
51 changes: 31 additions & 20 deletions crates/engine/src/parser/oracle_static/keyword_grant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,27 +234,38 @@ pub(crate) fn parse_spells_have_keyword_for_test(text: &str) -> Option<StaticDef
/// path uses (Dream Devourer). Table-driven so further cast-variant alt-cost
/// keywords that use this exact template slot in without new control flow. Only
/// keywords the casting flow already surfaces from `effective_spell_keywords`
/// (granted `CastingVariant::Blitz`) belong here, so the grant actually functions.
/// belong here, so the grant actually functions:
/// - `blitz` (Henzie "Toolbox" Torre) → granted `CastingVariant::Blitz`.
/// - `replicate` (Hatchery Sliver: "Each Sliver spell you cast has replicate.
/// The replicate cost is equal to its mana cost.") — CR 702.56a: the granted
/// `CastWithKeyword { Replicate(SelfManaCost) }` is read as a repeatable
/// additional cost by `effective_replicate_additional_cost_instances`, and
/// the per-instance copy trigger is synthesized by the
/// `dynamically_granted_replicate` seam in `game/triggers.rs`, so granted
/// Replicate functions end-to-end with no engine change.
fn parse_granted_self_cost_keyword(keyword_str: &str) -> Option<Keyword> {
[("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.
Expand Down
36 changes: 36 additions & 0 deletions crates/engine/src/parser/oracle_static/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading