From 4e83f700347a291e3858a4577a501d6915787f3b Mon Sep 17 00:00:00 2001 From: Jacob Woodson Date: Wed, 5 Aug 2026 06:32:47 -0500 Subject: [PATCH 1/3] Partial: Dragon Man, Reformed Robot --- crates/engine/src/game/casting.rs | 24 +- crates/engine/src/parser/oracle_casting.rs | 63 ++++- crates/engine/src/parser/oracle_cost.rs | 125 ++++++++++ crates/engine/src/parser/oracle_static/mod.rs | 2 +- .../src/parser/oracle_static/restriction.rs | 121 ++++++---- .../engine/src/parser/oracle_static/tests.rs | 221 ++++++++++++++++++ .../demilich_helbrute_graveyard_exile_cost.rs | 203 ++++++++++++++++ ...n_reformed_robot_graveyard_discard_cost.rs | 146 ++++++++++++ crates/engine/tests/integration/main.rs | 2 + 9 files changed, 848 insertions(+), 59 deletions(-) create mode 100644 crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs create mode 100644 crates/engine/tests/integration/dragon_man_reformed_robot_graveyard_discard_cost.rs diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 6f6dd8bdfe..91dce72cb8 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -13811,11 +13811,18 @@ fn can_cast_prepared_now_with_probe( } } - // CR 118.9 + CR 601.2f + CR 119.8: Graveyard/exile cast-permission statics - // that carry a pay-life extra-cost rider (Valgavoth alternative; Festival of - // Embers additional) must afford the life payment for the cast to be legal. - // The remove-counters extra-cost (Dawnhand) carries no life payment, so - // `find_pay_life_cost` returns `None` and this gate is a no-op for it. + // CR 118.3 + CR 118.9 + CR 601.2f + CR 601.2h + CR 119.8: Graveyard/exile + // cast-permission statics that carry a non-mana extra-cost rider (Valgavoth + // alternative pay-life; Festival of Embers additional pay-life; Dragon Man, + // Reformed Robot additional discard) must be able to pay that cost in full for + // the cast to be legal. Use the general affordability authority + // (`AbilityCost::is_payable`, mirroring the Flashback gate above) rather than a + // pay-life special case: `is_payable`'s PayLife arm calls the same + // `can_pay_life_cast_or_activation_cost`, so pay-life legality is unchanged, + // while discard/sacrifice/remove-counter riders are now correctly gated so + // legal actions never offer an unpayable cast (e.g. Dragon Man from an empty + // hand). Mode-agnostic: an unpayable Alternative or Additional cost both make + // the cast illegal. { // CR 601.2a: Bind the exile extra-cost rider to the source this cast // commits to — the recorded `ExilePermission` source if elected, else the @@ -13839,11 +13846,8 @@ fn can_cast_prepared_now_with_probe( _ => None, }; if let Some(extra) = static_extra { - if let Some(amount) = find_pay_life_cost(&extra.cost, state, player, prepared.object_id) - { - if !super::life_costs::can_pay_life_cast_or_activation_cost(state, player, amount) { - return false; - } + if !extra.cost.is_payable(state, player, prepared.object_id) { + return false; } } } diff --git a/crates/engine/src/parser/oracle_casting.rs b/crates/engine/src/parser/oracle_casting.rs index 67e0987e9d..0ee60d9765 100644 --- a/crates/engine/src/parser/oracle_casting.rs +++ b/crates/engine/src/parser/oracle_casting.rs @@ -6,7 +6,7 @@ use nom::combinator::{all_consuming, map, opt, value}; use nom::sequence::{preceded, terminated}; use nom::Parser; -use super::oracle_cost::parse_oracle_cost; +use super::oracle_cost::{parse_gerund_cost, parse_oracle_cost}; use super::oracle_util::{parse_mana_symbols, parse_ordinal, TextPair}; use crate::parser::oracle_condition::parse_restriction_condition; use crate::types::ability::{ @@ -222,7 +222,22 @@ fn parse_self_flash_option( if let Ok((after, _)) = tag::<_, _, OracleError<'_>>("by ").parse(rest) { if let Some(cost_text) = after.strip_suffix(" in addition to paying its other costs") { - option = option.cost(parse_oracle_cost(cost_text)); + // CR 601.2f: the rider names the additional cost as a GERUND ("by + // discarding a card") — de-gerund via the shared cost authority. A + // present-but-unmodeled cost DECLINES the whole option (return None), + // mirroring the graveyard `AdditionalCostRider::Unmodeled` decline in + // `oracle_static/restriction.rs`. Falling through to the cost-less + // `Some(option)` tail below would grant flash while silently skipping + // the required additional cost AND be marked supported=true by coverage + // (`build_casting_option_item` treats a `None` cost as supported) — + // strictly more permissive than the printed text. Declining keeps the + // spell sorcery-speed and leaves the dropped cost as an honest coverage + // gap (the unconsumed line falls through to gap detection). + let cost = parse_gerund_cost(cost_text); + if matches!(cost, AbilityCost::Unimplemented { .. }) { + return None; + } + option = option.cost(cost); return Some(option); } } @@ -1657,6 +1672,50 @@ Trample"; } } + /// CR 601.2f: a self-flash rider that names its additional cost as a GERUND + /// ("as though it had flash by discarding a card in addition to paying its + /// other costs") must de-gerund the cost via the shared authority and carry a + /// concrete Discard cost — not the `Unimplemented` the old imperative-only + /// `parse_oracle_cost(cost_text)` produced. Class completeness for the + /// "cast … by in addition to …" family alongside the graveyard rider. + #[test] + fn self_flash_by_gerund_additional_cost_carries_discard() { + let option = parse_spell_casting_option_line( + "You may cast this spell as though it had flash by discarding a card in addition to paying its other costs.", + "Test Card", + ) + .expect("self-flash rider should parse"); + match option { + SpellCastingOption { + kind: crate::types::ability::SpellCastingOptionKind::AsThoughHadFlash, + cost: Some(AbilityCost::Discard { .. }), + condition: None, + } => {} + other => panic!("expected AsThoughHadFlash with a Discard cost, got {other:?}"), + } + } + + /// The paired negative: an unmodeled gerund cost on the self-flash rider must + /// DECLINE the whole option (return `None`), mirroring the graveyard + /// `AdditionalCostRider::Unmodeled` decline — NOT emit a cost-less flash grant + /// (which coverage would falsely mark supported). Asserting `is_none()` is the + /// load-bearing, discriminating check: it flips to failure the instant the + /// guard falls through to the cost-less `Some(option)` tail. A `!matches!(cost, + /// Some(Unimplemented))` assertion would pass vacuously for that exact + /// (dishonest) `cost == None` outcome, so it cannot catch the regression. + #[test] + fn self_flash_by_unmodeled_gerund_declines_option() { + let option = parse_spell_casting_option_line( + "You may cast this spell as though it had flash by frobnicating a card in addition to paying its other costs.", + "Test Card", + ); + assert!( + option.is_none(), + "an unmodeled gerund additional cost must decline the whole self-flash \ + option (honest coverage gap), not emit a cost-less flash grant: {option:?}" + ); + } + #[test] fn alt_cost_sacrifice_typed_creature_arm() { // Delraich — "sacrifice three black creatures" diff --git a/crates/engine/src/parser/oracle_cost.rs b/crates/engine/src/parser/oracle_cost.rs index 5a44e68df8..5fe2689ee5 100644 --- a/crates/engine/src/parser/oracle_cost.rs +++ b/crates/engine/src/parser/oracle_cost.rs @@ -66,6 +66,46 @@ pub fn parse_oracle_cost(text: &str) -> AbilityCost { parse_oracle_cost_no_or(text) } +/// CR 601.2f: Parse a GERUND-form cost phrase ("discarding a card", "paying 1 +/// life", "sacrificing a creature") into an `AbilityCost` by de-conjugating the +/// leading verb to its imperative stem and delegating to [`parse_oracle_cost`], +/// the single cost authority. +/// +/// The gerund construction appears in "cast … by in addition to +/// (paying) its other costs" ADDITIONAL-cost riders (Festival of Embers pay-life; +/// Dragon Man, Reformed Robot discard; Demilich / Helbrute exile-from-graveyard) +/// and in the self-flash rider in `oracle_casting.rs`. English gerund→imperative +/// is irregular (pay→paying, discard→discarding, sacrifice→sacrificing[−e], +/// remove→removing[−e], exile→exiling[−e], tap→tapping[+p]), so it cannot be a +/// generic `strip_suffix("ing")`; each verb is one composed `value(stem, +/// tag(gerund))` arm. Extend by a single arm per cost verb, only once +/// `parse_oracle_cost` models its imperative. +/// +/// Returns `AbilityCost::Unimplemented { .. }` when the leading verb is not a +/// modeled cost gerund OR the delegated imperative is itself unmodeled, so +/// callers can decline (or drop) rather than silently attach a wrong/absent cost. +pub(crate) fn parse_gerund_cost(phrase: &str) -> AbilityCost { + type E<'a> = super::oracle_nom::error::OracleError<'a>; + let lower = phrase.trim().to_lowercase(); + // Compose one `value(stem, tag(gerund))` arm per cost verb — each maps a + // gerund onto the imperative stem `parse_oracle_cost` already recognizes. + let deconjugated = alt(( + value("pay", tag::<_, _, E<'_>>("paying ")), + value("discard", tag("discarding ")), + value("sacrifice", tag("sacrificing ")), + value("tap", tag("tapping ")), + value("remove", tag("removing ")), + value("exile", tag("exiling ")), + )) + .parse(lower.as_str()); + let Ok((rest, stem)) = deconjugated else { + return AbilityCost::Unimplemented { + description: phrase.trim().to_string(), + }; + }; + parse_oracle_cost(&format!("{stem} {rest}")) +} + /// True when a top-level ` or ` branch parsed to a concrete activation cost /// rather than falling through to `Unimplemented` / `EffectCost`. fn is_disjunctive_alt_cost(cost: &AbilityCost) -> bool { @@ -1795,6 +1835,19 @@ fn extract_filter_zone(filter: &TargetFilter) -> Option { None } }), + // Recurse into composite filters so a multi-type source-zone cost carries + // the same top-level `zone` a single-type one would. "Exile four instant + // and/or sorcery cards from your graveyard" (Demilich) lowers to an + // `Or([Typed{Instant, InZone(Graveyard)}, Typed{Sorcery, InZone(Graveyard)}])` + // filter; without this, `zone` stayed `None` and the payment layer's + // no-zone default (`exile_cost_effective_zone`) looked in the hand instead + // of the graveyard, making the cost unpayable and the card uncastable. + // Every leg of these disjunctions names the same zone, so the first leg + // that yields a zone is authoritative. + TargetFilter::Or { filters } | TargetFilter::And { filters } => { + filters.iter().find_map(extract_filter_zone) + } + TargetFilter::Not { filter } => extract_filter_zone(filter), _ => None, } } @@ -1892,6 +1945,78 @@ mod tests { assert_eq!(parse_oracle_cost("{T}"), AbilityCost::Tap); } + /// CR 601.2f: `parse_gerund_cost` de-conjugates the gerund verb and delegates + /// to the single cost authority, so a gerund cost phrase lowers identically to + /// its imperative form across the whole verb class — and an unmodeled verb + /// stays honest `Unimplemented`. Tests the building block, not one card. + #[test] + fn gerund_cost_matches_imperative_authority() { + for (gerund, imperative) in [ + ("discarding a card", "discard a card"), + ("paying 1 life", "pay 1 life"), + ("sacrificing a creature", "sacrifice a creature"), + // CR 701.13a: the exile arm — Demilich / Helbrute cast-from-graveyard + // riders exile cards as an additional cost. + ( + "exiling four instant and/or sorcery cards from your graveyard", + "exile four instant and/or sorcery cards from your graveyard", + ), + ( + "exiling another creature card from your graveyard", + "exile another creature card from your graveyard", + ), + ] { + assert_eq!( + parse_gerund_cost(gerund), + parse_oracle_cost(imperative), + "gerund {gerund:?} must lower like imperative {imperative:?}" + ); + } + // The required-for-this-fix arm is concretely a discard-a-card cost. + assert!( + matches!( + parse_gerund_cost("discarding a card"), + AbilityCost::Discard { .. } + ), + "discarding a card must lower to a Discard cost" + ); + // CR 701.13a: the exile arm lowers to a real graveyard Exile cost — the + // regression that turned Demilich/Helbrute from castable-with-dropped-cost + // into declined-and-uncastable is fixed at its root (the missing gerund). + assert!( + matches!( + parse_gerund_cost("exiling four instant and/or sorcery cards from your graveyard"), + AbilityCost::Exile { + count: 4, + zone: Some(Zone::Graveyard), + filter: Some(_), + } + ), + "Demilich's exile-four rider must lower to an Exile-from-graveyard cost, got {:?}", + parse_gerund_cost("exiling four instant and/or sorcery cards from your graveyard") + ); + assert!( + matches!( + parse_gerund_cost("exiling another creature card from your graveyard"), + AbilityCost::Exile { + count: 1, + zone: Some(Zone::Graveyard), + filter: Some(_), + } + ), + "Helbrute's exile-another-creature rider must lower to an Exile-from-graveyard cost, got {:?}", + parse_gerund_cost("exiling another creature card from your graveyard") + ); + // A verb the cost authority does not model stays honest. + assert!( + matches!( + parse_gerund_cost("frobnicating a card"), + AbilityCost::Unimplemented { .. } + ), + "an unmodeled gerund verb must lower to Unimplemented" + ); + } + #[test] fn cost_explicit_count_continuation_with_unmodeled_rider_stays_unimplemented() { // Terminal explicit-count guard: a "=2 …" continuation whose object diff --git a/crates/engine/src/parser/oracle_static/mod.rs b/crates/engine/src/parser/oracle_static/mod.rs index a7572a7e40..b3bfedf4bc 100644 --- a/crates/engine/src/parser/oracle_static/mod.rs +++ b/crates/engine/src/parser/oracle_static/mod.rs @@ -15,7 +15,7 @@ mod prelude { pub(super) use nom::sequence::{preceded, terminated}; pub(super) use nom::Parser; - pub(super) use super::super::oracle_cost::parse_oracle_cost; + pub(super) use super::super::oracle_cost::{parse_gerund_cost, parse_oracle_cost}; pub(super) use super::super::oracle_effect::subject::{ parse_restriction_modes, static_mode_needs_grant_propagation, }; diff --git a/crates/engine/src/parser/oracle_static/restriction.rs b/crates/engine/src/parser/oracle_static/restriction.rs index 170ebfacc4..7a80f4895d 100644 --- a/crates/engine/src/parser/oracle_static/restriction.rs +++ b/crates/engine/src/parser/oracle_static/restriction.rs @@ -1992,14 +1992,21 @@ pub(crate) fn try_parse_graveyard_cast_permission( let graveyard_destination_replacement = parse_exile_spell_cast_this_way_rider(trailing) .is_ok() .then_some(Zone::Exile); - // CR 601.2f: Optional "by paying ... in addition to their other costs" - // ADDITIONAL non-mana cost rider (Festival of Embers). Recognized before the - // permission-condition fallback so it isn't misread as a condition tail. - let extra_cost = - parse_cast_permission_additional_cost_rider(trailing).map(|cost| CastExtraCost { + // CR 601.2f: Optional "by in addition to (paying )?(their|its) other + // costs" ADDITIONAL non-mana cost rider (Festival of Embers pay-life; Dragon + // Man, Reformed Robot discard). Recognized before the permission-condition + // fallback so it isn't misread as a condition tail. A present-but-unmodeled + // rider DECLINES the whole permission so the dropped cost surfaces as an + // honest coverage gap instead of a strictly-more-permissive misparse (a cast + // that silently skips a required additional cost). + let extra_cost = match parse_cast_permission_additional_cost_rider(trailing) { + AdditionalCostRider::Absent => None, + AdditionalCostRider::Parsed(cost) => Some(CastExtraCost { cost, mode: CastCostMode::Additional, - }); + }), + AdditionalCostRider::Unmodeled => return None, + }; // `.trim()` (not `.is_empty()`): after the enters-with rider is split off, a // two-sentence "if X. If you do, Y." permission leaves a whitespace-only // residual (Undead Sprinter) that must still be treated as fully consumed so @@ -2036,49 +2043,71 @@ pub(crate) fn try_parse_graveyard_cast_permission( Some(def) } +/// CR 601.2f: Outcome of matching the "by in addition to … other costs" +/// ADDITIONAL-cost rider on a cast-from-zone permission. A plain `Option` would +/// conflate two structurally distinct outcomes — "no rider present" and "rider +/// present but its cost verb is not yet modeled" — which is precisely how the +/// silent-drop bug survived: an unmodeled rider read as `None` (Absent) emitted a +/// permission that skipped a required cost. The three-state enum forces the +/// caller to decide emit-vs-decline explicitly. +enum AdditionalCostRider { + /// No additional-cost rider is present (Lurrus/Karador/Conduit). + Absent, + /// Rider present and its cost lowered to a concrete `AbilityCost`. + Parsed(crate::types::ability::AbilityCost), + /// Rider present but the cost verb/phrase is not yet modeled — the caller + /// must DECLINE the whole permission so the dropped cost surfaces as an + /// honest coverage gap (Unimplemented) instead of a strictly-more-permissive + /// misparse (CR 601.2f: an additional cost that must be paid). + Unmodeled, +} + /// CR 601.2f: Parse a trailing ADDITIONAL-cost rider on a cast-from-zone -/// permission — "by paying life in addition to their other costs" (Festival -/// of Embers). The cost is paid on TOP of the spell's normal mana cost (CR -/// 601.2f), distinct from the CR 118.9 alternative rider parsed by -/// `oracle_effect::try_parse_alt_cost_rider`. Composed from nom combinators so -/// the prefix × quantity × suffix axes stay independent and future shapes -/// (other costs, "its"/"their" pronoun) extend without permutation blowup. -/// Returns `None` when the rider shape is absent. -fn parse_cast_permission_additional_cost_rider( - trailing: &str, -) -> Option { - let lower = trailing.trim_start(); - // CR 601.2f: "by paying " opens the rider; "in addition to" distinguishes - // the additional shape from a CR 118.9 "rather than" alternative. - if !nom_primitives::scan_contains(lower, "in addition to") { - return None; +/// permission — "by in addition to (paying )?(their|its) other costs" +/// (Festival of Embers pay-life; Dragon Man, Reformed Robot discard). The cost is +/// paid on TOP of the spell's normal mana cost (CR 601.2f), distinct from the CR +/// 118.9 "rather than" alternative rider parsed by +/// `oracle_effect::try_parse_alt_cost_rider`. Cost semantics delegate to +/// [`parse_gerund_cost`] → the single cost authority, so the whole CR 601.2f +/// non-mana verb class (pay life, discard, sacrifice, tap, remove counters) is +/// covered rather than pay-life alone. The mode is fixed to `Additional` by the +/// "in addition to … other costs" closer. Composed from nom combinators so the +/// prefix × cost × closer axes stay independent. Returns [`AdditionalCostRider`] +/// so a present-but-unmodeled rider (`Unmodeled`) is distinguished from an absent +/// one (`Absent`) — see the enum doc for why the distinction is load-bearing. +fn parse_cast_permission_additional_cost_rider(trailing: &str) -> AdditionalCostRider { + let trimmed = trailing.trim_start(); + // CR 601.2f: "by " opens the rider (the cost verb follows as a gerund). + let Some(rest) = nom_tag_lower(trimmed, trimmed, "by ") else { + return AdditionalCostRider::Absent; + }; + // CR 601.2f vs CR 118.9: split the cost phrase off the "in addition to … + // other costs" closer. Its absence means this is not an ADDITIONAL rider (it + // may be a "rather than" alternative or an unrelated tail) — treat as absent. + let Ok((_, (cost_phrase, tail))) = nom_primitives::split_once_on(rest, " in addition to ") + else { + return AdditionalCostRider::Absent; + }; + // CR 601.2f: consume the closer — optional "paying " gerund (Noctis, Prince + // of Lucis) then the required "(their|its) other costs" pronoun. The additive + // strip leaves Festival's bare "their other costs" slice unchanged. + let tail = nom_tag_lower(tail, tail, "paying ").unwrap_or(tail); + let Some(after) = nom_tag_lower(tail, tail, "their other costs") + .or_else(|| nom_tag_lower(tail, tail, "its other costs")) + else { + return AdditionalCostRider::Unmodeled; + }; + let after = after.trim_start(); + let after = after.strip_prefix('.').unwrap_or(after); // allow-noncombinator: punctuation cleanup on a pre-tokenized chunk, not parsing dispatch. + if !after.trim().is_empty() { + return AdditionalCostRider::Unmodeled; } - let rest = nom_tag_lower(lower, lower, "by paying ")?; - // CR 119.4: " life" — the only additional-cost shape used by the current - // class that this permission carries (Festival of Embers). - let (after_num, n) = nom_primitives::parse_number(rest).ok()?; - let after_life = nom_tag_lower(after_num, after_num, " life")?; - // CR 601.2f: the tail must be the "in addition to (their|its) other costs" - // closer — anything else is an unmodeled shape. - let after_life = after_life.trim_start(); - let after_in_addition = nom_tag_lower(after_life, after_life, "in addition to ")?; - // CR 601.2f: tolerate the optional "paying" gerund — "in addition to PAYING - // their other costs" (Noctis, Prince of Lucis) alongside the bare "in - // addition to their other costs" (Festival of Embers). Additive `opt` strip: - // Festival (no gerund) keeps the original slice unchanged. - let after_in_addition = - nom_tag_lower(after_in_addition, after_in_addition, "paying ").unwrap_or(after_in_addition); - let after_pronoun = nom_tag_lower(after_in_addition, after_in_addition, "their other costs") - .or_else(|| nom_tag_lower(after_in_addition, after_in_addition, "its other costs"))?; - // allow-noncombinator: punctuation cleanup (drop the sentence terminator) on a pre-tokenized chunk, not parsing dispatch. - let trimmed_pronoun = after_pronoun.trim_start(); - let after_pronoun = trimmed_pronoun.strip_prefix('.').unwrap_or(trimmed_pronoun); // allow-noncombinator: punctuation cleanup on a pre-tokenized chunk, not parsing dispatch. - if !after_pronoun.trim().is_empty() { - return None; + // CR 601.2f: lower the gerund cost via the single cost authority. An + // unmodeled verb yields `Unimplemented` → decline the whole permission. + match parse_gerund_cost(cost_phrase) { + crate::types::ability::AbilityCost::Unimplemented { .. } => AdditionalCostRider::Unmodeled, + cost => AdditionalCostRider::Parsed(cost), } - Some(crate::types::ability::AbilityCost::PayLife { - amount: QuantityExpr::Fixed { value: n as i32 }, - }) } /// CR 607.1 + CR 122.1 + CR 614.1c: Peel the linked "if you cast a spell this diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 26e43febd4..2c43341eca 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -14220,6 +14220,227 @@ fn graveyard_cast_permission_festival_additional_pay_life() { ); } +/// CR 601.2f + CR 701.9a: Dragon Man, Reformed Robot — "You may cast this card +/// from your graveyard by discarding a card in addition to paying its other +/// costs." lowers to a graveyard-cast permission carrying an ADDITIONAL discard +/// cost. Regression for the misparse where the whole non-pay-life additional-cost +/// class was dropped (`extra_cost: None`), letting the card be recast from the +/// graveyard for its mana cost alone with no discard. +#[test] +fn graveyard_cast_permission_dragon_man_additional_discard() { + use crate::types::ability::{AbilityCost, CardSelectionMode, DiscardSelfScope, QuantityExpr}; + use crate::types::statics::{CastCostMode, CastExtraCost}; + let text = "You may cast this card from your graveyard by discarding a card in addition to paying its other costs."; + let def = parse_static_line(text).expect("Dragon Man static must parse"); + let StaticMode::GraveyardCastPermission { + play_mode, + ref extra_cost, + .. + } = def.mode + else { + panic!("expected GraveyardCastPermission, got {:?}", def.mode); + }; + // Positive reach-guard: the permission is emitted (not declined) as a + // graveyard self-cast, so the discard rides a real permission. + assert_eq!(play_mode, CardPlayMode::Cast); + assert_eq!( + def.active_zones, + vec![Zone::Graveyard], + "the \"this card … from your graveyard\" self-reference must scope the \ + permission to the graveyard" + ); + // The dropped-clause regression: the discard additional cost must be present, + // and identical to the single cost authority's lowering of "discard a card". + assert_eq!( + extra_cost, + &Some(CastExtraCost { + cost: AbilityCost::Discard { + count: QuantityExpr::Fixed { value: 1 }, + filter: None, + selection: CardSelectionMode::Chosen, + self_scope: DiscardSelfScope::FromHand, + }, + mode: CastCostMode::Additional, + }), + "expected an additional discard-a-card extra cost, got {extra_cost:?}" + ); + + // Full Oracle dispatch (with real "~" normalization) must route the third + // line to the same static, leaving no Unimplemented node behind for it. + let card_text = "Flying\nDragon Man's power is equal to the greatest mana value among noncreature permanents you control and noncreature cards in your graveyard.\nYou may cast this card from your graveyard by discarding a card in addition to paying its other costs."; + let parsed = crate::parser::oracle::parse_oracle_text( + card_text, + "Dragon Man, Reformed Robot", + &[], + &["Artifact".to_string(), "Creature".to_string()], + &["Dragon".to_string(), "Robot".to_string()], + ); + assert!( + parsed + .statics + .iter() + .any(|parsed_def| parsed_def.mode == def.mode), + "full Oracle dispatch must route Dragon Man's line to the discard-cost \ + permission, got {:?}", + parsed.statics + ); +} + +/// CR 601.2f + CR 701.13a: Demilich — "You may cast this card from your graveyard +/// by exiling four instant and/or sorcery cards from your graveyard in addition +/// to paying its other costs." lowers to a graveyard-cast permission carrying an +/// ADDITIONAL exile-four-from-graveyard cost. Regression: adding the discard/etc. +/// gerund class WITHOUT an `exiling` arm made this rider `Unmodeled`, so the whole +/// permission was DECLINED (`None`) — turning Demilich from castable-from-graveyard +/// (cost silently dropped) into uncastable-from-graveyard, masked by green +/// coverage. The `exiling` arm both restores castability AND models the real cost. +#[test] +fn graveyard_cast_permission_demilich_additional_exile() { + use crate::types::ability::AbilityCost; + use crate::types::statics::{CastCostMode, CastExtraCost}; + let text = "You may cast this card from your graveyard by exiling four instant and/or sorcery cards from your graveyard in addition to paying its other costs."; + let def = parse_static_line(text).expect( + "Demilich static must parse (not decline) — the exile rider is a modeled additional cost", + ); + let StaticMode::GraveyardCastPermission { + play_mode, + ref extra_cost, + .. + } = def.mode + else { + panic!("expected GraveyardCastPermission, got {:?}", def.mode); + }; + // Positive reach-guard: the permission is emitted (not declined) as a + // graveyard self-cast, so the exile cost rides a real permission. + assert_eq!(play_mode, CardPlayMode::Cast); + assert_eq!( + def.active_zones, + vec![Zone::Graveyard], + "the \"this card … from your graveyard\" self-reference must scope the \ + permission to the graveyard" + ); + // The dropped-clause regression: the additional exile cost must be present and + // be a real graveyard exile of four cards (not None, not Unimplemented). + let Some(CastExtraCost { + cost: + AbilityCost::Exile { + count, + zone, + filter, + }, + mode, + }) = extra_cost + else { + panic!("expected an additional Exile extra cost, got {extra_cost:?}"); + }; + assert_eq!(*mode, CastCostMode::Additional); + assert_eq!(*count, 4, "Demilich exiles four cards"); + assert_eq!(*zone, Some(Zone::Graveyard)); + assert!( + filter.is_some(), + "the exile cost must carry the instant/sorcery card filter, got {filter:?}" + ); + + // Full Oracle dispatch (with real "~" normalization) must route the graveyard + // line to the same static, leaving no Unimplemented node behind for it. + let card_text = "This spell costs {U} less to cast for each instant and sorcery spell you've cast this turn.\nYou may cast this card from your graveyard by exiling four instant and/or sorcery cards from your graveyard in addition to paying its other costs."; + let parsed = crate::parser::oracle::parse_oracle_text( + card_text, + "Demilich", + &[], + &["Creature".to_string()], + &["Skeleton".to_string(), "Wizard".to_string()], + ); + assert!( + parsed + .statics + .iter() + .any(|parsed_def| parsed_def.mode == def.mode), + "full Oracle dispatch must route Demilich's line to the exile-cost \ + permission, got {:?}", + parsed.statics + ); +} + +/// CR 601.2f + CR 701.13a: Helbrute — "Sarcophagus — You may cast this card from +/// your graveyard by exiling another creature card from your graveyard in addition +/// to paying its other costs." lowers to a graveyard-cast permission carrying an +/// ADDITIONAL exile-a-creature-card cost. Same regression class as Demilich; the +/// ability-word prefix ("Sarcophagus —") is stripped upstream before the line +/// reaches the permission parser (verified via the full-dispatch check below). +#[test] +fn graveyard_cast_permission_helbrute_additional_exile() { + use crate::types::ability::AbilityCost; + use crate::types::statics::{CastCostMode, CastExtraCost}; + // The ability-word prefix is stripped upstream; the permission parser sees the + // bare "You may cast …" line. + let text = "You may cast this card from your graveyard by exiling another creature card from your graveyard in addition to paying its other costs."; + let def = parse_static_line(text).expect( + "Helbrute static must parse (not decline) — the exile rider is a modeled additional cost", + ); + let StaticMode::GraveyardCastPermission { + play_mode, + ref extra_cost, + .. + } = def.mode + else { + panic!("expected GraveyardCastPermission, got {:?}", def.mode); + }; + assert_eq!(play_mode, CardPlayMode::Cast); + assert_eq!(def.active_zones, vec![Zone::Graveyard]); + let Some(CastExtraCost { + cost: AbilityCost::Exile { count, zone, .. }, + mode, + }) = extra_cost + else { + panic!("expected an additional Exile extra cost, got {extra_cost:?}"); + }; + assert_eq!(*mode, CastCostMode::Additional); + assert_eq!(*count, 1, "Helbrute exiles one other creature card"); + assert_eq!(*zone, Some(Zone::Graveyard)); + + // Full Oracle dispatch, including the "Sarcophagus —" ability word and Haste, + // must route the graveyard line to the same permission with no Unimplemented. + let card_text = "Haste\nSarcophagus — You may cast this card from your graveyard by exiling another creature card from your graveyard in addition to paying its other costs."; + let parsed = crate::parser::oracle::parse_oracle_text( + card_text, + "Helbrute", + &[], + &["Artifact".to_string(), "Creature".to_string()], + &["Astartes".to_string(), "Dreadnought".to_string()], + ); + assert!( + parsed + .statics + .iter() + .any(|parsed_def| parsed_def.mode == def.mode), + "full Oracle dispatch must route Helbrute's line (past the ability word) \ + to the exile-cost permission, got {:?}", + parsed.statics + ); +} + +/// CR 601.2f: an additional-cost rider whose cost verb is not yet modeled must +/// DECLINE the whole permission (honest coverage gap) rather than emit a +/// permission that silently skips the required cost. Paired with the modeled +/// discard line so the decline is proven cost-specific, not a blanket refusal. +#[test] +fn graveyard_cast_permission_unmodeled_additional_cost_declines() { + let modeled = + "You may cast this card from your graveyard by discarding a card in addition to paying its other costs."; + let unmodeled = + "You may cast this card from your graveyard by frobnicating a card in addition to paying its other costs."; + assert!( + try_parse_graveyard_cast_permission(modeled, &modeled.to_lowercase()).is_some(), + "the modeled discard rider must still emit a permission" + ); + assert!( + try_parse_graveyard_cast_permission(unmodeled, &unmodeled.to_lowercase()).is_none(), + "an unmodeled additional-cost verb must decline the whole permission, not \ + emit one that drops the required cost" + ); +} + /// Issue #1524 — Serpent's Soul-Jar: persistent exile pool without "this turn". #[test] fn exile_cast_permission_soul_jar_persistent_creature_pool() { diff --git a/crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs b/crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs new file mode 100644 index 0000000000..c409595829 --- /dev/null +++ b/crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs @@ -0,0 +1,203 @@ +//! Demilich & Helbrute — graveyard cast permission with an ADDITIONAL exile cost +//! (CR 601.2f + CR 701.13a). +//! +//! Demilich: "You may cast this card from your graveyard by exiling four instant +//! and/or sorcery cards from your graveyard in addition to paying its other +//! costs." +//! Helbrute: "Sarcophagus — You may cast this card from your graveyard by exiling +//! another creature card from your graveyard in addition to paying its other +//! costs." +//! +//! These are the exile-cost siblings of Dragon Man's discard rider. The regression +//! under guard: when the CR 601.2f additional-cost rider learned to DECLINE on an +//! unmodeled gerund, `parse_gerund_cost` had no `exiling` arm, so both riders +//! lowered to `Unimplemented` and the whole permission was declined — turning both +//! cards from castable-from-graveyard (with the exile cost silently dropped) into +//! entirely uncastable-from-graveyard, masked by green coverage. Adding the +//! `exiling` arm both restores castability AND models the real exile cost. +//! +//! These tests drive the real cast pipeline: the exile is INTERACTIVE — the exiled +//! cards are declared via `.pay_cost_with(..)` at the announcement-time `PayCost` +//! window. Reverting the parser fix (dropping the `exiling` arm) declines the +//! permission, so `can_cast_object_now` returns false and `.resolve()` cannot +//! commit the cast — every assertion below flips. + +use engine::game::casting::can_cast_object_now; +use engine::game::scenario::{GameScenario, P0}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const DEMILICH_ORACLE: &str = "This spell costs {U} less to cast for each instant and sorcery spell you've cast this turn.\n\ +You may cast this card from your graveyard by exiling four instant and/or sorcery cards from your graveyard in addition to paying its other costs."; + +const HELBRUTE_ORACLE: &str = "Haste\n\ +Sarcophagus — You may cast this card from your graveyard by exiling another creature card from your graveyard in addition to paying its other costs."; + +fn pool_units(colors: &[ManaType]) -> Vec { + let dummy = ObjectId(0); + colors + .iter() + .map(|&color| ManaUnit::new(color, dummy, false, vec![])) + .collect() +} + +/// {U}{U}{U}{U} — Demilich's printed mana cost, still due as an ADDITIONAL rider. +fn demilich_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ + ManaCostShard::Blue, + ManaCostShard::Blue, + ManaCostShard::Blue, + ManaCostShard::Blue, + ], + generic: 0, + } +} + +/// {3}{B}{R} — Helbrute's printed mana cost. +fn helbrute_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ManaCostShard::Black, ManaCostShard::Red], + generic: 3, + } +} + +fn stage_demilich(scenario: &mut GameScenario) -> ObjectId { + scenario + .add_creature_to_graveyard(P0, "Demilich", 4, 4) + .with_subtypes(vec!["Skeleton", "Wizard"]) + .with_mana_cost(demilich_cost()) + .from_oracle_text(DEMILICH_ORACLE) + .id() +} + +/// Stage four instant/sorcery cards (2 + 2) in P0's graveyard, all eligible for +/// the "instant and/or sorcery" exile filter. +fn stage_four_spells(scenario: &mut GameScenario) -> Vec { + vec![ + scenario + .add_spell_to_graveyard(P0, "Lightning Bolt", true) + .id(), + scenario.add_spell_to_graveyard(P0, "Opt", true).id(), + scenario + .add_spell_to_graveyard(P0, "Divination", false) + .id(), + scenario.add_spell_to_graveyard(P0, "Ponder", false).id(), + ] +} + +/// CR 601.2f + CR 701.13a: end-to-end — casting Demilich from the graveyard pays +/// its {U}{U}{U}{U} AND exiles four instant/sorcery cards from the graveyard. +/// DISCRIMINATING: reverting the parser (no `exiling` arm) makes the rider +/// `Unimplemented`, so the permission is declined and Demilich is never offered as +/// a cast — the cast cannot be committed and Demilich stays in the graveyard, so +/// both the battlefield assertion and the four exile assertions flip. +#[test] +fn demilich_graveyard_cast_exiles_four_spells() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain).with_life(P0, 20); + let demilich_id = stage_demilich(&mut scenario); + let spells = stage_four_spells(&mut scenario); + // Pool covers {U}{U}{U}{U}. + scenario.with_mana_pool(P0, pool_units(&[ManaType::Blue; 4])); + let mut runner = scenario.build(); + + let outcome = runner.cast(demilich_id).pay_cost_with(&spells).resolve(); + + assert_eq!( + outcome.zone_of(demilich_id), + Zone::Battlefield, + "Demilich resolves onto the battlefield after its additional exile cost is paid" + ); + for &spell in &spells { + assert_eq!( + outcome.zone_of(spell), + Zone::Exile, + "each declared instant/sorcery card must actually be exiled to pay the cost" + ); + } +} + +/// CR 601.2h: the additional exile is affordability-gated. With only three +/// exilable instant/sorcery cards, the mandatory "exile four" cost is unpayable, +/// so the graveyard cast must not be offered. Paired in-test positive reach-guard: +/// with a fourth eligible card (same mana pool) the cast IS offered — proving the +/// block is affordability-specific, not a blanket refusal. +/// DISCRIMINATING: reverting the parser declines the permission unconditionally, +/// so the four-card positive reach-guard also returns false and fails. +#[test] +fn demilich_graveyard_cast_blocked_without_four_exilable_cards() { + // Only three eligible cards — the "exile four" cost is unpayable. + let mut blocked = GameScenario::new(); + blocked.at_phase(Phase::PreCombatMain).with_life(P0, 20); + let demilich_blocked = stage_demilich(&mut blocked); + blocked.add_spell_to_graveyard(P0, "Lightning Bolt", true); + blocked.add_spell_to_graveyard(P0, "Opt", true); + blocked.add_spell_to_graveyard(P0, "Divination", false); + blocked.with_mana_pool(P0, pool_units(&[ManaType::Blue; 4])); + let blocked_runner = blocked.build(); + assert!( + !can_cast_object_now(blocked_runner.state(), P0, demilich_blocked), + "with only three exilable cards the mandatory exile-four additional cost \ + (CR 601.2h) is unpayable, so the graveyard cast must not be offered" + ); + + // Positive reach-guard: a fourth eligible card makes the exact same cast legal. + let mut allowed = GameScenario::new(); + allowed.at_phase(Phase::PreCombatMain).with_life(P0, 20); + let demilich_allowed = stage_demilich(&mut allowed); + stage_four_spells(&mut allowed); + allowed.with_mana_pool(P0, pool_units(&[ManaType::Blue; 4])); + let allowed_runner = allowed.build(); + assert!( + can_cast_object_now(allowed_runner.state(), P0, demilich_allowed), + "with four exilable cards the additional exile cost is payable, so the \ + graveyard cast must be offered — proves the block is affordability-specific" + ); +} + +/// CR 601.2f + CR 701.13a: end-to-end — casting Helbrute from the graveyard pays +/// its {3}{B}{R} AND exiles another creature card from the graveyard. The +/// ability-word prefix ("Sarcophagus —") and Haste keyword ride the same card. +/// DISCRIMINATING: reverting the parser declines the permission, so Helbrute is +/// never offered as a cast and stays in the graveyard — both assertions flip. +#[test] +fn helbrute_graveyard_cast_exiles_another_creature_card() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain).with_life(P0, 20); + let helbrute_id = scenario + .add_creature_to_graveyard(P0, "Helbrute", 6, 6) + .with_mana_cost(helbrute_cost()) + .from_oracle_text(HELBRUTE_ORACLE) + .id(); + // A second creature card in the graveyard satisfies "another creature card". + let fodder = scenario + .add_creature_to_graveyard(P0, "Grizzly Bears", 2, 2) + .id(); + scenario.with_mana_pool( + P0, + pool_units(&[ + ManaType::Black, + ManaType::Red, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ]), + ); + let mut runner = scenario.build(); + + let outcome = runner.cast(helbrute_id).pay_cost_with(&[fodder]).resolve(); + + assert_eq!( + outcome.zone_of(helbrute_id), + Zone::Battlefield, + "Helbrute resolves onto the battlefield after its additional exile cost is paid" + ); + assert_eq!( + outcome.zone_of(fodder), + Zone::Exile, + "the declared other creature card must actually be exiled to pay the cost" + ); +} diff --git a/crates/engine/tests/integration/dragon_man_reformed_robot_graveyard_discard_cost.rs b/crates/engine/tests/integration/dragon_man_reformed_robot_graveyard_discard_cost.rs new file mode 100644 index 0000000000..16b261900b --- /dev/null +++ b/crates/engine/tests/integration/dragon_man_reformed_robot_graveyard_discard_cost.rs @@ -0,0 +1,146 @@ +//! Dragon Man, Reformed Robot — graveyard cast permission with an ADDITIONAL +//! discard cost (CR 601.2f + CR 701.9a). +//! +//! "You may cast this card from your graveyard by discarding a card in addition +//! to paying its other costs." +//! +//! The permission keeps the spell's printed mana cost (CR 601.2f: an ADDITIONAL +//! cost, not an alternative one) and requires discarding a card on top. Unlike +//! Festival of Embers' pay-life cost, the discard is INTERACTIVE — the discarded +//! card must be declared via `.pay_cost_with(..)` (a bare `.resolve()` submits an +//! empty selection, which the engine rejects). These tests drive the real cast +//! pipeline and prove (a) the discard is actually paid, (b) the cast is illegal +//! with no discardable card, and (c) the discard is mandatory. +//! +//! Dragon Man is modeled here as a Legendary Creature (the Artifact core type is +//! not load-bearing for the discard-cost behavior under test); its verbatim +//! Oracle text drives the parser as it would in production. + +use engine::game::casting::can_cast_object_now; +use engine::game::scenario::{GameScenario, P0}; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const DRAGON_MAN_ORACLE: &str = "Flying\n\ +Dragon Man's power is equal to the greatest mana value among noncreature permanents you control and noncreature cards in your graveyard.\n\ +You may cast this card from your graveyard by discarding a card in addition to paying its other costs."; + +fn pool_units(colors: &[ManaType]) -> Vec { + let dummy = engine::types::identifiers::ObjectId(0); + colors + .iter() + .map(|&color| ManaUnit::new(color, dummy, false, vec![])) + .collect() +} + +/// {2}{W}{U} — Dragon Man's printed mana cost, still due as an ADDITIONAL rider. +fn dragon_man_cost() -> ManaCost { + ManaCost::Cost { + shards: vec![ManaCostShard::White, ManaCostShard::Blue], + generic: 2, + } +} + +/// Pool that covers {2}{W}{U}: W, U, and two colorless for the generic {2}. +fn full_pool() -> Vec { + pool_units(&[ + ManaType::White, + ManaType::Blue, + ManaType::Colorless, + ManaType::Colorless, + ]) +} + +fn stage_dragon_man(scenario: &mut GameScenario) -> engine::types::identifiers::ObjectId { + scenario + .add_creature_to_graveyard(P0, "Dragon Man, Reformed Robot", 0, 5) + .as_legendary() + .with_subtypes(vec!["Dragon", "Robot"]) + .with_mana_cost(dragon_man_cost()) + .from_oracle_text(DRAGON_MAN_ORACLE) + .id() +} + +/// CR 601.2f + CR 701.9a: end-to-end — casting Dragon Man from the graveyard pays +/// its {2}{W}{U} AND discards a card. DISCRIMINATING: reverting the parser so the +/// permission's `extra_cost` is `None` removes the discard requirement, so the +/// declared hand card would stay in hand (`Zone::Hand`) instead of moving to the +/// graveyard — the assertion flips. +#[test] +fn dragon_man_graveyard_cast_requires_discard() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain).with_life(P0, 20); + let dragon_man_id = stage_dragon_man(&mut scenario); + let hand_card = scenario.add_card_to_hand(P0, "Forest"); + scenario.with_mana_pool(P0, full_pool()); + let mut runner = scenario.build(); + + // The discard is interactive — declare which card pays it (CR 601.2b). + let outcome = runner + .cast(dragon_man_id) + .pay_cost_with(&[hand_card]) + .resolve(); + + assert_eq!( + outcome.zone_of(hand_card), + Zone::Graveyard, + "the declared discard must actually be paid — the hand card moves to the graveyard" + ); + assert_eq!( + outcome.zone_of(dragon_man_id), + Zone::Battlefield, + "Dragon Man resolves onto the battlefield after its additional discard cost is paid" + ); +} + +/// CR 601.2h + CR 118.3: an unpayable additional cost makes the cast illegal. +/// With P0's hand empty, there is no card to discard, so legal actions must NOT +/// offer the graveyard cast. DISCRIMINATING: reverting the legality gate to the +/// pay-life-only `find_pay_life_cost` check makes it a no-op for `Discard`, so it +/// returns `true` and this assertion fails. The paired positive +/// (`dragon_man_graveyard_cast_requires_discard`, same mana pool) proves the +/// block is affordability-specific, not a blanket refusal. +#[test] +fn dragon_man_graveyard_cast_blocked_without_discardable_card() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain).with_life(P0, 20); + let dragon_man_id = stage_dragon_man(&mut scenario); + // P0's hand is empty — nothing to discard for the mandatory additional cost. + scenario.with_mana_pool(P0, full_pool()); + let runner = scenario.build(); + + assert!( + !can_cast_object_now(runner.state(), P0, dragon_man_id), + "with no discardable card the mandatory additional discard cost (CR 601.2h) \ + is unpayable, so the graveyard cast must not be offered" + ); +} + +/// CR 601.2f: the additional discard is MANDATORY. A card IS available to discard +/// (so the cost is payable), but the caster declines to declare one — the driver +/// then submits an empty discard selection. Discarding nothing cannot satisfy the +/// count-1 cost, so the engine rejects the cast. This proves the `min_count: 0` +/// lower bound on the `PayCost` window does not permit paying nothing on this +/// path (enforcement is in `handle_discard_for_cost`, which requires +/// `chosen.len() == count`). DISCRIMINATING: reverting the parser removes the +/// discard window entirely, so the cast succeeds and `is_err()` fails. +#[test] +fn dragon_man_graveyard_discard_is_mandatory() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain).with_life(P0, 20); + let dragon_man_id = stage_dragon_man(&mut scenario); + // A discardable card exists, so the cost is payable — but we do NOT declare it. + let _hand_card = scenario.add_card_to_hand(P0, "Forest"); + scenario.with_mana_pool(P0, full_pool()); + let mut runner = scenario.build(); + + // No `.pay_cost_with(..)` — the driver submits an empty discard selection. + let result = runner.cast(dragon_man_id).try_resolve(); + + assert!( + result.is_err(), + "casting Dragon Man from the graveyard without discarding a card must be \ + rejected — the additional discard cost is mandatory" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 82b946b824..b261434d9a 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -144,6 +144,7 @@ mod daretti_emblem_simultaneous_death; mod dark_confidant_upkeep; mod dark_depths_thespian_stage; mod death_priest_myrkul_oxford_anthem; +mod demilich_helbrute_graveyard_exile_cost; mod demon_of_fates_design; mod descendants_fury_sacrificed_referent_4795; mod destroy_redirect_to_battlefield_delivery_tail; @@ -160,6 +161,7 @@ mod disorder_in_the_court_5955; mod divine_visitation_token_substitution; mod doran_attack_block_pump; mod double_strike_first_strike_trigger_removes_attacker; +mod dragon_man_reformed_robot_graveyard_discard_cost; mod dragonstorm_forecaster_named_or_tutor; mod draw_delivery_preview; mod draw_from_general_post_replacement; From 2de7357c007faf2653489e65826f74a5e1011426 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 20:33:13 -0700 Subject: [PATCH 2/3] fix(engine): harden additional casting costs --- crates/engine/src/game/cost_payability.rs | 31 ++++++- crates/engine/src/parser/oracle_casting.rs | 87 ++++++++++++------- crates/engine/src/parser/oracle_cost.rs | 34 +++++--- .../engine/src/parser/oracle_static/tests.rs | 31 ++++++- .../demilich_helbrute_graveyard_exile_cost.rs | 15 ++++ 5 files changed, 150 insertions(+), 48 deletions(-) diff --git a/crates/engine/src/game/cost_payability.rs b/crates/engine/src/game/cost_payability.rs index 2acfe89580..6a99d48f59 100644 --- a/crates/engine/src/game/cost_payability.rs +++ b/crates/engine/src/game/cost_payability.rs @@ -22,7 +22,7 @@ use crate::types::ability::{ is_variable_remove_counter_cost_count, AbilityCost, Comparator, CounterCostSelection, FilterProp, QuantityExpr, QuantityRef, TapCreaturesAggregateStat, TapCreaturesRequirement, - TargetFilter, TypedFilter, + TargetFilter, TypedFilter, EXILE_COST_X, }; use crate::types::card_type::CoreType; use crate::types::identifiers::ObjectId; @@ -406,6 +406,13 @@ impl AbilityCost { zone, filter, } => { + // CR 107.3a + CR 601.2b: X in this cost is chosen during + // announcement. X=0 is legal, so the pre-announcement + // affordability gate must not treat its compact sentinel as a + // literal count that can never be met. + if *count == EXILE_COST_X { + return true; + } if matches!(filter, Some(TargetFilter::SelfRef)) { // CR 118.3 + CR 602.1a: "Exile this " as an // activation cost needs the source available to pay that @@ -1246,6 +1253,28 @@ mod tests { ); } + #[test] + fn variable_exile_cost_is_payable_at_x_zero() { + let mut scenario = GameScenario::new(); + let source = scenario.add_creature(P0, "Harvest Pyre", 0, 1).id(); + let cost = AbilityCost::Exile { + count: EXILE_COST_X, + zone: Some(Zone::Graveyard), + filter: Some(TargetFilter::Typed(TypedFilter::new(TypeFilter::Instant))), + }; + + assert!( + cost.is_payable(&scenario.state, P0, source), + "X exile costs are payable at X=0 before any eligible card is selected" + ); + + scenario.add_spell_to_graveyard(P0, "Lightning Bolt", true); + assert!( + cost.is_payable(&scenario.state, P0, source), + "X exile costs stay payable when eligible cards can set X above zero" + ); + } + #[test] fn loyalty_positive_is_always_payable() { let state = new_state(); diff --git a/crates/engine/src/parser/oracle_casting.rs b/crates/engine/src/parser/oracle_casting.rs index dad1a1332c..05f9707791 100644 --- a/crates/engine/src/parser/oracle_casting.rs +++ b/crates/engine/src/parser/oracle_casting.rs @@ -220,26 +220,42 @@ fn parse_self_flash_option( } } - if let Ok((after, _)) = tag::<_, _, OracleError<'_>>("by ").parse(rest) { - if let Some(cost_text) = after.strip_suffix(" in addition to paying its other costs") { - // CR 601.2f: the rider names the additional cost as a GERUND ("by - // discarding a card") — de-gerund via the shared cost authority. A - // present-but-unmodeled cost DECLINES the whole option (return None), - // mirroring the graveyard `AdditionalCostRider::Unmodeled` decline in - // `oracle_static/restriction.rs`. Falling through to the cost-less - // `Some(option)` tail below would grant flash while silently skipping - // the required additional cost AND be marked supported=true by coverage - // (`build_casting_option_item` treats a `None` cost as supported) — - // strictly more permissive than the printed text. Declining keeps the - // spell sorcery-speed and leaves the dropped cost as an honest coverage - // gap (the unconsumed line falls through to gap detection). - let cost = parse_gerund_cost(cost_text); - if matches!(cost, AbilityCost::Unimplemented { .. }) { - return None; - } - option = option.cost(cost); - return Some(option); + if let Some(((), after)) = nom_on_lower(rest, &rest.to_lowercase(), |input| { + value((), tag::<_, _, OracleError<'_>>("by ")).parse(input) + }) { + let after = after.trim(); + let after_lower = after.to_lowercase(); + // CR 601.2f: the trailing closer has the same independent axes as the + // graveyard permission parser: optional "paying " and either possessive + // pronoun. A malformed closer must decline the whole option rather than + // fall through to an uncosted flash grant. + let Some((cost_len, _)) = nom_on_lower(after, &after_lower, |input| { + all_consuming(map( + ( + terminated( + take_until::<_, _, OracleError<'_>>(" in addition to "), + tag(" in addition to "), + ), + opt(tag("paying ")), + alt((tag("their other costs"), tag("its other costs"))), + opt(tag(".")), + ), + |(cost, _, _, _)| cost.len(), + )) + .parse(input) + }) else { + return None; + }; + // CR 601.2f: the rider names the additional cost as a GERUND ("by + // discarding a card") — de-gerund via the shared cost authority. A + // present-but-unmodeled cost declines the whole option, avoiding a + // strictly-more-permissive cost-less flash permission. + let cost = parse_gerund_cost(&after[..cost_len]); + if matches!(cost, AbilityCost::Unimplemented { .. }) { + return None; } + option = option.cost(cost); + return Some(option); } if let Ok((after, _)) = tag::<_, _, OracleError<'_>>("if you ").parse(rest) { @@ -1662,18 +1678,27 @@ Trample"; /// "cast … by in addition to …" family alongside the graveyard rider. #[test] fn self_flash_by_gerund_additional_cost_carries_discard() { - let option = parse_spell_casting_option_line( - "You may cast this spell as though it had flash by discarding a card in addition to paying its other costs.", - "Test Card", - ) - .expect("self-flash rider should parse"); - match option { - SpellCastingOption { - kind: crate::types::ability::SpellCastingOptionKind::AsThoughHadFlash, - cost: Some(AbilityCost::Discard { .. }), - condition: None, - } => {} - other => panic!("expected AsThoughHadFlash with a Discard cost, got {other:?}"), + for closer in [ + "its other costs", + "their other costs", + "paying its other costs", + "paying their other costs", + ] { + let option = parse_spell_casting_option_line( + &format!( + "You may cast this spell as though it had flash by discarding a card in addition to {closer}." + ), + "Test Card", + ) + .expect("self-flash rider should parse"); + match option { + SpellCastingOption { + kind: crate::types::ability::SpellCastingOptionKind::AsThoughHadFlash, + cost: Some(AbilityCost::Discard { .. }), + condition: None, + } => {} + other => panic!("expected AsThoughHadFlash with a Discard cost, got {other:?}"), + } } } diff --git a/crates/engine/src/parser/oracle_cost.rs b/crates/engine/src/parser/oracle_cost.rs index 5fe2689ee5..16e27b1948 100644 --- a/crates/engine/src/parser/oracle_cost.rs +++ b/crates/engine/src/parser/oracle_cost.rs @@ -86,21 +86,23 @@ pub fn parse_oracle_cost(text: &str) -> AbilityCost { /// callers can decline (or drop) rather than silently attach a wrong/absent cost. pub(crate) fn parse_gerund_cost(phrase: &str) -> AbilityCost { type E<'a> = super::oracle_nom::error::OracleError<'a>; - let lower = phrase.trim().to_lowercase(); + let original = phrase.trim(); + let lower = original.to_lowercase(); // Compose one `value(stem, tag(gerund))` arm per cost verb — each maps a // gerund onto the imperative stem `parse_oracle_cost` already recognizes. - let deconjugated = alt(( - value("pay", tag::<_, _, E<'_>>("paying ")), - value("discard", tag("discarding ")), - value("sacrifice", tag("sacrificing ")), - value("tap", tag("tapping ")), - value("remove", tag("removing ")), - value("exile", tag("exiling ")), - )) - .parse(lower.as_str()); - let Ok((rest, stem)) = deconjugated else { + let Some((stem, rest)) = nom_on_lower(original, &lower, |input| { + alt(( + value("pay", tag::<_, _, E<'_>>("paying ")), + value("discard", tag("discarding ")), + value("sacrifice", tag("sacrificing ")), + value("tap", tag("tapping ")), + value("remove", tag("removing ")), + value("exile", tag("exiling ")), + )) + .parse(input) + }) else { return AbilityCost::Unimplemented { - description: phrase.trim().to_string(), + description: original.to_string(), }; }; parse_oracle_cost(&format!("{stem} {rest}")) @@ -1955,6 +1957,7 @@ mod tests { ("discarding a card", "discard a card"), ("paying 1 life", "pay 1 life"), ("sacrificing a creature", "sacrifice a creature"), + ("sacrificing a Vehicle", "sacrifice a Vehicle"), // CR 701.13a: the exile arm — Demilich / Helbrute cast-from-graveyard // riders exile cards as an additional cost. ( @@ -1972,6 +1975,13 @@ mod tests { "gerund {gerund:?} must lower like imperative {imperative:?}" ); } + assert!(matches!( + parse_gerund_cost("sacrificing a Vehicle"), + AbilityCost::Sacrifice(SacrificeCost { + target: TargetFilter::Typed(TypedFilter { type_filters, .. }), + .. + }) if type_filters == [TypeFilter::Subtype("Vehicle".to_string())] + )); // The required-for-this-fix arm is concretely a discard-a-card cost. assert!( matches!( diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index e5dbefb784..73f24557b6 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -14336,10 +14336,22 @@ fn graveyard_cast_permission_demilich_additional_exile() { assert_eq!(*mode, CastCostMode::Additional); assert_eq!(*count, 4, "Demilich exiles four cards"); assert_eq!(*zone, Some(Zone::Graveyard)); - assert!( - filter.is_some(), - "the exile cost must carry the instant/sorcery card filter, got {filter:?}" + let Some(TargetFilter::Or { filters }) = filter else { + panic!("Demilich's exile cost must be an instant-or-sorcery filter, got {filter:?}"); + }; + assert_eq!( + filters.len(), + 2, + "instant-or-sorcery must have two filter legs" ); + assert!(filters.iter().any(|filter| matches!( + filter, + TargetFilter::Typed(typed) if typed.type_filters == [TypeFilter::Instant] + ))); + assert!(filters.iter().any(|filter| matches!( + filter, + TargetFilter::Typed(typed) if typed.type_filters == [TypeFilter::Sorcery] + ))); // Full Oracle dispatch (with real "~" normalization) must route the graveyard // line to the same static, leaving no Unimplemented node behind for it. @@ -14389,7 +14401,12 @@ fn graveyard_cast_permission_helbrute_additional_exile() { assert_eq!(play_mode, CardPlayMode::Cast); assert_eq!(def.active_zones, vec![Zone::Graveyard]); let Some(CastExtraCost { - cost: AbilityCost::Exile { count, zone, .. }, + cost: + AbilityCost::Exile { + count, + zone, + filter, + }, mode, }) = extra_cost else { @@ -14398,6 +14415,12 @@ fn graveyard_cast_permission_helbrute_additional_exile() { assert_eq!(*mode, CastCostMode::Additional); assert_eq!(*count, 1, "Helbrute exiles one other creature card"); assert_eq!(*zone, Some(Zone::Graveyard)); + assert!(matches!( + filter, + Some(TargetFilter::Typed(typed)) + if typed.type_filters == [TypeFilter::Creature] + && typed.properties.contains(&FilterProp::Another) + )); // Full Oracle dispatch, including the "Sarcophagus —" ability word and Haste, // must route the graveyard line to the same permission with no Unimplemented. diff --git a/crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs b/crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs index c409595829..50811fb69e 100644 --- a/crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs +++ b/crates/engine/tests/integration/demilich_helbrute_graveyard_exile_cost.rs @@ -156,6 +156,21 @@ fn demilich_graveyard_cast_blocked_without_four_exilable_cards() { "with four exilable cards the additional exile cost is payable, so the \ graveyard cast must be offered — proves the block is affordability-specific" ); + + // A non-spell graveyard card cannot inflate the filter-specific exile count. + let mut ineligible = GameScenario::new(); + ineligible.at_phase(Phase::PreCombatMain).with_life(P0, 20); + let demilich_ineligible = stage_demilich(&mut ineligible); + ineligible.add_spell_to_graveyard(P0, "Lightning Bolt", true); + ineligible.add_spell_to_graveyard(P0, "Opt", true); + ineligible.add_spell_to_graveyard(P0, "Divination", false); + ineligible.add_creature_to_graveyard(P0, "Grizzly Bears", 2, 2); + ineligible.with_mana_pool(P0, pool_units(&[ManaType::Blue; 4])); + let ineligible_runner = ineligible.build(); + assert!( + !can_cast_object_now(ineligible_runner.state(), P0, demilich_ineligible), + "three instant/sorcery cards plus an ineligible creature cannot pay Demilich's exile-four cost" + ); } /// CR 601.2f + CR 701.13a: end-to-end — casting Helbrute from the graveyard pays From 2ae0461bcfab6498c5f74038eb9a811806a31eaa Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 21:31:11 -0700 Subject: [PATCH 3/3] fix(PR-7030): satisfy self-flash lint --- crates/engine/src/parser/oracle_casting.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/parser/oracle_casting.rs b/crates/engine/src/parser/oracle_casting.rs index 05f9707791..94af942bf4 100644 --- a/crates/engine/src/parser/oracle_casting.rs +++ b/crates/engine/src/parser/oracle_casting.rs @@ -229,7 +229,7 @@ fn parse_self_flash_option( // graveyard permission parser: optional "paying " and either possessive // pronoun. A malformed closer must decline the whole option rather than // fall through to an uncosted flash grant. - let Some((cost_len, _)) = nom_on_lower(after, &after_lower, |input| { + let (cost_len, _) = nom_on_lower(after, &after_lower, |input| { all_consuming(map( ( terminated( @@ -243,9 +243,7 @@ fn parse_self_flash_option( |(cost, _, _, _)| cost.len(), )) .parse(input) - }) else { - return None; - }; + })?; // CR 601.2f: the rider names the additional cost as a GERUND ("by // discarding a card") — de-gerund via the shared cost authority. A // present-but-unmodeled cost declines the whole option, avoiding a