Skip to content
Merged
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
156 changes: 153 additions & 3 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9403,10 +9403,25 @@ fn audit_card_lines(oracle_text: &str, face: &CardFace) -> Vec<SemanticFinding>
effective_lower.contains("prevent") && effective_lower.contains("damage")
}
Effect::CopySpell { .. } => {
// "You may have this creature enter as a copy of ..." lines
// (including "enter tapped as a copy of")
// Parsed as CopySpell without a description string.
// CR 707.5: clone-permanent copies enter "as a copy of ..."
// (including "enter tapped as a copy of").
// CR 707.10: to copy a spell is to put a copy of it onto the
// stack. A CopySpell is parsed without a description string, so
// it is matched here by effect type. Spell copies — "copy that
// spell", "copy it", "copy target instant or sorcery spell" —
// frequently nest
// inside a CreateDelayedTrigger ("When you next cast ...
// this turn, copy that spell", CR 603.7b), reached via
// ability_tree_any's CreateDelayedTrigger recursion. The
// retarget rider ("you may choose new targets for the
// copy") is CR 707.10c. Covers Galvanic Iteration /
// Doublecast / Dual Strike / Twincast / Fork.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
effective_lower.contains("as a copy of")
|| (effective_lower.contains("copy")
&& (effective_lower.contains("that spell")
|| effective_lower.contains("copy it")
|| (effective_lower.contains("copy target")
&& effective_lower.contains("spell"))))
}
Effect::CastCopyOfCard { .. } => {
effective_lower.contains("copy") && effective_lower.contains("cast the copy")
Expand Down Expand Up @@ -13687,6 +13702,141 @@ mod tests {
);
}

#[test]
fn delayed_spell_copy_line_is_not_a_silent_drop() {
// CR 707.10 / CR 603.7b: "When you next cast an instant or sorcery spell
// this turn, copy that spell. You may choose new targets for the copy."
// parses to a description-less CopySpell nested inside a
// CreateDelayedTrigger. The description matcher misses (no description
// string at any level), so coverage must come from the effect-type
// fallback reaching the nested CopySpell via ability_tree_any's
// CreateDelayedTrigger recursion. Covers the whole delayed spell-copy
// class (Galvanic Iteration / Doublecast / Dual Strike), not one card.
// The delayed-trigger condition variant is immaterial to the seam under
// test (the audit inspects only the effect subtree for coverage), so a
// minimal AtNextPhase stands in for the real WhenNextEvent.
use crate::types::ability::CopyRetargetPermission;

let delayed_copy = || {
AbilityDefinition::new(
AbilityKind::Spell,
Effect::CreateDelayedTrigger {
condition: DelayedTriggerCondition::AtNextPhase { phase: Phase::End },
effect: Box::new(AbilityDefinition::new(
AbilityKind::Spell,
Effect::CopySpell {
target: TargetFilter::TriggeringSource,
retarget: CopyRetargetPermission::MayChooseNewTargets,
copier: None,
additional_modifications: vec![],
starting_loyalty_from_casualty_sacrifice: false,
},
)),
uses_tracked_set: false,
},
)
};

for oracle in [
// Galvanic Iteration / Doublecast
"When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy.",
// Dual Strike — mana-value-restricted variant of the same class
"When you next cast an instant or sorcery spell with mana value 4 or less this turn, copy that spell. You may choose new targets for the copy.",
] {
let mut face = make_face();
face.oracle_text = Some(oracle.to_string());
face.abilities.push(delayed_copy());
let findings = audit_card_lines(oracle, &face);
assert!(
findings.is_empty(),
"delayed spell-copy line falsely flagged: {oracle} -> {findings:?}"
);
}
}

#[test]
fn direct_spell_copy_line_without_description_is_not_a_silent_drop() {
// CR 707.10: "Copy target instant or sorcery spell. You may choose new
// targets for the copy." (Twincast / Fork). The real printings carry an
// ability description that the description matcher catches, but a
// description-less CopySpell of the same direct-copy class must still be
// covered by the effect-type fallback rather than flagged as a SilentDrop.
use crate::types::ability::CopyRetargetPermission;

let oracle =
"Copy target instant or sorcery spell. You may choose new targets for the copy.";
let mut face = make_face();
face.oracle_text = Some(oracle.to_string());
face.abilities.push(AbilityDefinition::new(
AbilityKind::Spell,
Effect::CopySpell {
target: TargetFilter::Any,
retarget: CopyRetargetPermission::MayChooseNewTargets,
copier: None,
additional_modifications: vec![],
starting_loyalty_from_casualty_sacrifice: false,
},
));
let findings = audit_card_lines(oracle, &face);
assert!(
findings.is_empty(),
"direct spell-copy line falsely flagged: {findings:?}"
);
}

#[test]
fn spell_copy_effect_does_not_cover_unparsed_ability_copy_line() {
// CR 707.10 distinguishes copying a spell from copying an activated
// ability. The face-wide CopySpell fallback must not hide a separate,
// unparsed ability-copy line.
use crate::types::ability::CopyRetargetPermission;

let oracle = "Copy target instant or sorcery spell.\nCopy target activated ability.";
let mut face = make_face();
face.oracle_text = Some(oracle.to_string());
face.abilities.push(AbilityDefinition::new(
AbilityKind::Spell,
Effect::CopySpell {
target: TargetFilter::Any,
retarget: CopyRetargetPermission::MayChooseNewTargets,
copier: None,
additional_modifications: vec![],
starting_loyalty_from_casualty_sacrifice: false,
},
));

let findings = audit_card_lines(oracle, &face);
assert!(
findings
.iter()
.any(|f| matches!(f, SemanticFinding::SilentDrop { .. })),
"unparsed ability-copy line must remain visible: {findings:?}"
);
}

#[test]
fn spell_copy_line_without_copyspell_effect_is_still_a_silent_drop() {
// Reach-guard (non-vacuous): proves the negatives above are caused by the
// CopySpell arm actually reaching the effect — not by the line being
// skipped for an unrelated reason. The same "... copy that spell ..." line
// on a face whose only effect is an unimplemented stub (no CopySpell) MUST
// still surface as a SilentDrop.
let oracle = "When you next cast an instant or sorcery spell this turn, copy that spell. You may choose new targets for the copy.";
let mut face = make_face();
face.oracle_text = Some(oracle.to_string());
face.abilities.push(AbilityDefinition::new(
AbilityKind::Spell,
Effect::unimplemented("copy that spell", oracle),
));
let findings = audit_card_lines(oracle, &face);
assert!(
findings
.iter()
.any(|f| matches!(f, SemanticFinding::SilentDrop { .. })),
"spell-copy line without a CopySpell effect must remain a SilentDrop: {findings:?}"
);
}

/// Regression: `AbilityCondition::IsYourTurn` is handled at runtime by
/// `evaluate_condition`; the compiler-checked classifier must report it
/// as `Handled` so cards like Rapier Wit aren't flagged as having an
Expand Down
Loading