diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 26c71023e5..1198bb050e 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -37,7 +37,6 @@ use super::conditions::ability_condition_to_static_condition; use super::lower::{ append_remember_card_to_standalone_exiled_choice, apply_where_x_ability_expression, apply_where_x_to_latest_def, attach_alt_ability_cost_to_previous_play_from_exile, - attach_any_color_mana_rider_to_previous_play_from_exile, attach_cast_cost_modifier_to_previous_play_from_exile, attach_cast_cost_modifier_to_prior_cast_from_zone, attach_graveyard_redirect_rider_to_prior_cast_from_zone, @@ -53,8 +52,7 @@ use super::lower::{ fold_exile_resolving_rider, fold_search_choose_type_conditional_destination, fold_token_it_has_grants_into_token_statics, gate_other_revealed_card_on_multiplayer_reveal, gate_reflexive_rider_on_declined_optional_target, is_exile_until_cast_bottom_cleanup, - is_land_enters_tapped_rider, is_linked_exile_cast_bottom_cleanup, - is_spend_mana_as_any_color_rider, is_stable_branch_amount, + is_land_enters_tapped_rider, is_linked_exile_cast_bottom_cleanup, is_stable_branch_amount, nest_whenever_this_turn_token_cleanup_delayed_trigger, normalize_exile_until_cast_bottom_cleanup, normalize_linked_exile_cast_bottom_cleanup, parse_controlled_by_different_players_target_constraint, @@ -73,7 +71,8 @@ use super::sequence::{apply_clause_continuation, def_bears_retargetable_copy}; use super::{ append_to_deepest_sub_ability, apply_player_scope_rewrites, attach_alt_cost_to_prior_cast_from_zone, attach_mana_retention_to_prior_mana, - attach_perpetual_keyword_grants, attach_repeat_process_keywords, attach_same_is_true_keywords, + attach_mana_spend_permission_to_prior_cast_grant, attach_perpetual_keyword_grants, + attach_repeat_process_keywords, attach_same_is_true_keywords, bind_anaphoric_damage_subject_keep_recipient, collapse_ephemeral_color_choice_mana, contains_explicit_tracked_set_pronoun, contains_implicit_tracked_set_pronoun, def_is_damage_dealer, def_is_dig_look, def_is_dig_or_mill, def_is_generic_effect_head, @@ -2223,6 +2222,22 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { PriorModifier::ManaRetention(expiry) => { attach_mana_retention_to_prior_mana(&mut defs, *expiry); } + PriorModifier::ManaSpendPermission(permission) => { + // CR 609.4b: the rider was admitted only because the + // clause it follows grants a cast without a concession + // (`prior_clause_grants_a_cast_without_mana_spend_permission`), + // so the stamp lands on that grant — the last def. + let stamped = attach_mana_spend_permission_to_prior_cast_grant( + &mut defs, + *permission, + ); + debug_assert!( + stamped, + "CR 609.4b: a mana rider admitted for the prior cast grant found \ + no grant to stamp on the last def: {:?}", + defs.last() + ); + } PriorModifier::EntersTappedAttacking => { // CR 508.4 / CR 614.1: Conditional enters-tapped-attacking modifier. // U6-C2: LastEmitted + an EffectShape guard. A wrong-shaped prior @@ -2589,18 +2604,6 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { continue; } - // CR 609.4b + CR 608.2c: Brainstealer/Daxos-class any-color mana - // riders may be split into their own sentence or comma sibling after a - // `PlayFromExile` grant. They scope the existing exile-play - // permission, so fold the rider into the prior grant instead of - // emitting a broad standalone `SpendManaAsAnyColor` effect. - if is_spend_mana_as_any_color_rider(clause_ir) - && attach_any_color_mana_rider_to_previous_play_from_exile(&mut defs) - { - prev_boundary = clause_ir.boundary; - continue; - } - // CR 614.1a + CR 608.2g: An exact "if a spell cast this way would be // put into a graveyard" rider scopes each cast in the immediately prior // free-cast window. Absorb it before the legacy singular-spell route; diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 8b0a74d60b..759452349d 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -44,7 +44,7 @@ use crate::types::ability::{ use crate::types::counter::CounterType; use crate::types::game_state::{DistributionUnit, TargetSelectionConstraint}; use crate::types::phase::Phase; -use crate::types::statics::{CostModifyMode, StaticMode}; +use crate::types::statics::CostModifyMode; use crate::types::zones::{EtbTapState, Zone}; // Parse-phase functions from the parent module (oracle_effect/mod.rs). @@ -989,72 +989,6 @@ pub(super) fn normalize_exile_until_cast_bottom_cleanup(effect: &mut Effect) { } } -pub(super) fn is_spend_mana_as_any_color_rider(clause: &ClauseIr) -> bool { - let Effect::GenericEffect { - static_abilities, .. - } = &clause.parsed.effect - else { - return false; - }; - if static_abilities.len() != 1 - || static_abilities[0].mode - != (StaticMode::SpendManaAsAnyColor { - spell_filter: None, - activation_source_filter: None, - }) - { - return false; - } - - let lower = clause - .source - .fragment() - .unwrap_or_default() - .to_ascii_lowercase(); - let parsed = all_consuming(( - opt(alt(( - tag::<_, _, OracleError<'_>>("if you cast a spell this way, "), - tag("if you cast it this way, "), - ))), - tag("you may spend mana as though it were mana of any "), - alt((tag("color"), tag("type"))), - tag(" to cast "), - alt(( - tag("it"), - tag("that spell"), - tag("a spell this way"), - tag("spells this way"), - tag("those spells"), - )), - opt(tag(".")), - )) - .parse(lower.trim()) - .is_ok(); - parsed -} - -pub(super) fn attach_any_color_mana_rider_to_previous_play_from_exile( - defs: &mut [AbilityDefinition], -) -> bool { - let Some(previous) = defs.last_mut() else { - return false; - }; - let Effect::GrantCastingPermission { - permission: - CastingPermission::PlayFromExile { - mana_spend_permission, - .. - }, - .. - } = previous.effect.as_mut() - else { - return false; - }; - - *mana_spend_permission = Some(ManaSpendPermission::AnyTypeOrColor); - true -} - /// CR 614.1a + CR 608.2n: Fold a "if that spell would be put into a graveyard, /// [put it on the library / return it to its owner's hand] instead" rider onto /// the immediately-preceding optional `CastFromZone` as its canonical diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 4ccd8df866..a8654de932 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -14308,8 +14308,8 @@ fn try_parse_per_grantee_play_grant(tp: TextPair<'_>) -> Option) -> Option { let (permission_text, explicit_duration) = strip_trailing_duration(tp.original); @@ -14388,14 +14388,14 @@ fn try_parse_cast_from_tracked_exile_grant(tp: TextPair<'_>) -> Option) -> Option>(", and ").parse(rest).ok()?; + let (rest, _) = tag::<_, _, OracleError<'_>>(", and mana of any ") + .parse(rest) + .ok()?; + let (rest, mana_spend_permission) = parse_any_mana_word(rest).ok()?; + // "… to cast that spell" (Hostage Taker), "… to cast it" (Court of + // Locthwain, Blightwing Bandit), "… to cast a spell this way". let (rest, _) = alt(( - tag::<_, _, OracleError<'_>>("mana of any type can be spent to cast that spell"), - tag("mana of any color can be spent to cast that spell"), - tag("mana of any type can be spent to cast a spell this way"), - tag("mana of any color can be spent to cast a spell this way"), + tag::<_, _, OracleError<'_>>(" can be spent to cast that spell"), + tag(" can be spent to cast it"), + tag(" can be spent to cast a spell this way"), )) .parse(rest) .ok()?; @@ -14523,7 +14527,7 @@ fn try_parse_exile_play_grant_with_any_mana(tp: TextPair<'_>) -> Option) -> Option Option { let tp = tp.trim_end_matches('.'); - // CR 118.9 + CR 609.4b: The any-mana conjunct must win over the bare - // per-grantee branch so "they may play that card ... mana of any type can - // be spent to cast a spell this way" (Gonti, Night Minister) keeps - // `mana_spend_permission: AnyTypeOrColor`. + // CR 118.14 + CR 609.4b: The any-mana conjunct must win over the bare + // per-grantee branch so "you may cast that card for as long as it remains + // exiled, and mana of any type can be spent to cast that spell" (Hostage + // Taker, Thief of Sanity) keeps `mana_spend_permission: AnyTypeOrColor`. if let Some(clause) = try_parse_exile_play_grant_with_any_mana(tp) { return Some(clause); } @@ -14825,15 +14829,18 @@ fn try_parse_play_the_exiled_card_grant(tp: TextPair) -> Option ParsedEffectCl } // CR 609.4b: "spend mana as though it were mana of any [color|type] to cast ..." / - // "mana of any type can be spent to cast ..." — grants any-type/any-color mana - // permission for a cast-from-exile card (Outrageous Robbery's "any type" rider). - // Produce a GenericEffect with SpendManaAsAnyColor static. + // "mana of any type can be spent to cast ..." with no cast grant before it + // in the chunk sequence (a rider after a grant is folded onto the grant by + // `try_parse_mana_spend_rider` earlier). Produce a GenericEffect with + // SpendManaAsAnyColor static. // Variants: "spend colorless mana as though..." / "mana of any color to cast..." { let lower = text.to_lowercase(); @@ -28719,9 +28727,7 @@ fn try_parse_cast_target_from_graveyard_any_mana(text: &str, ctx: &ParseContext) .parse(body) .ok()?; let (rest, _) = tag::<_, _, E>(", and mana of any ").parse(rest).ok()?; - let (rest, _) = alt((tag::<_, _, E>("type"), tag("color"))) - .parse(rest) - .ok()?; + let (rest, concession) = parse_any_mana_word(rest).ok()?; let (rest, _) = tag::<_, _, E>(" can be spent to cast ").parse(rest).ok()?; let (rest, _) = alt((tag::<_, _, E>("that spell"), tag("a spell this way"))) .parse(rest) @@ -28744,8 +28750,8 @@ fn try_parse_cast_target_from_graveyard_any_mana(text: &str, ctx: &ParseContext) else { return None; }; - // CR 609.4b: scope the any-type concession to this specific granted cast. - *mana_spend_permission = Some(ManaSpendPermission::AnyTypeOrColor); + // CR 118.14 + CR 609.4b: scope the concession to this specific granted cast. + *mana_spend_permission = Some(concession); // CR 608.2g: "cast target ... from a graveyard" with no duration is a // during-resolution paid cast, not a lingering permission. *driver = crate::types::ability::CastFromZoneDriver::DuringResolution; @@ -28964,6 +28970,184 @@ pub(crate) fn try_parse_alt_cost_rider(text: &str) -> Option OracleResult<'_, ManaSpendPermission> { + alt(( + value(ManaSpendPermission::AnyColor, tag("color")), + value(ManaSpendPermission::AnyTypeOrColor, tag("type")), + )) + .parse(input) +} + +/// CR 118.14 + CR 609.4b: The any-color / any-type mana rider that follows a cast grant — +/// "[you may] spend mana as though it were mana of any color to cast that +/// spell" (Siphon Insight, Robber of the Rich), "Mana of any type can be spent +/// to cast spells this way" (Black Cat, Cunning Thief, The Madcap Jester). +/// The rider states no permission of its own: CR 118.14 has it apply only to +/// mana spent casting through the PRECEDING grant, so it is a `PriorModifier`, +/// not an effect. The word after "any" decides the permission +/// (`parse_any_mana_word`); a rider that relaxes only one kind of mana is +/// reported as `ManaSpendRider::SingleKind`. +pub(crate) fn try_parse_mana_spend_rider(text: &str) -> Option { + type Vbe<'a> = OracleError<'a>; + let lower = text.to_lowercase(); + let trimmed = lower.trim().trim_end_matches('.'); + let cast_object = || { + alt(( + tag::<_, _, Vbe>("that spell"), + tag("those spells"), + tag("spells this way"), + tag("spells cast this way"), + tag("a spell this way"), + tag("them"), + tag("it"), + )) + }; + // "If you cast a spell this way, mana of any type can be spent to cast it" + // (Bloodsoaked Insight): the gate restates what the rider already means — + // it applies only to a cast made through the grant — so it is dropped, as + // `try_parse_alt_cost_rider` drops the same prefix. + let (trimmed, _) = opt(alt(( + tag::<_, _, Vbe>("if you cast a spell this way, "), + tag("if you cast it this way, "), + ))) + .parse(trimmed) + .ok()?; + // "spend as though it were mana of any …": "spend mana" is the + // concession the engine models; any other subject ("colorless mana", + // "mana from snow sources") relaxes a single kind. + let (rest, rider) = alt(( + map( + preceded( + ( + opt(tag::<_, _, Vbe>("you may ")), + tag("spend mana as though it were mana of any "), + ), + parse_any_mana_word, + ), + ManaSpendRider::Concession, + ), + value( + ManaSpendRider::SingleKind, + ( + opt(tag::<_, _, Vbe>("you may ")), + tag("spend "), + take_until(" as though it were mana of any "), + tag(" as though it were mana of any "), + parse_any_mana_word, + ), + ), + map( + preceded( + tag("mana of any "), + terminated(parse_any_mana_word, tag(" can be spent")), + ), + ManaSpendRider::Concession, + ), + )) + .parse(trimmed) + .ok()?; + let (rest, _) = tag::<_, _, Vbe>(" to cast ").parse(rest).ok()?; + let (rest, _) = cast_object().parse(rest).ok()?; + eof::<_, Vbe>(rest).ok()?; + Some(rider) +} + +/// CR 609.4b: Does the most recently emitted clause (descending its +/// `sub_ability` chain) grant a cast the mana rider can attach to — a +/// `CastFromZone` or a `GrantCastingPermission { PlayFromExile }` that carries +/// no `mana_spend_permission` yet? The rider only ever modifies the grant it +/// follows, so a rider with no such antecedent keeps today's standalone +/// lowering instead of folding onto an unrelated earlier grant. +fn prior_clause_grants_a_cast_without_mana_spend_permission(clauses: &[ClauseIr]) -> bool { + fn effect_awaits(effect: &Effect) -> bool { + matches!( + effect, + Effect::CastFromZone { + mana_spend_permission: None, + .. + } | Effect::GrantCastingPermission { + permission: CastingPermission::PlayFromExile { + mana_spend_permission: None, + .. + }, + .. + } + ) + } + fn def_awaits(def: &AbilityDefinition) -> bool { + def.sub_ability.as_deref().is_some_and(def_awaits) || effect_awaits(&def.effect) + } + clauses + .iter() + .rev() + .find(|clause| !matches!(clause.disposition, ClauseDisposition::Continue { .. })) + .is_some_and(|clause| { + effect_awaits(&clause.parsed.effect) + || clause.parsed.sub_ability.as_deref().is_some_and(def_awaits) + }) +} + +/// CR 609.4b: Stamp `permission` onto the cast grant the rider follows — the +/// LAST emitted def, descending its `sub_ability` chain to the deepest +/// `CastFromZone` or `GrantCastingPermission { PlayFromExile }` that carries no +/// `mana_spend_permission`. The same def +/// `prior_clause_grants_a_cast_without_mana_spend_permission` admitted the +/// rider for, so the two never name different grants. Returns `true` when a +/// grant was stamped. +pub(crate) fn attach_mana_spend_permission_to_prior_cast_grant( + defs: &mut [AbilityDefinition], + permission: ManaSpendPermission, +) -> bool { + fn walk(def: &mut AbilityDefinition, permission: ManaSpendPermission) -> bool { + if let Some(sub) = def.sub_ability.as_mut() { + if walk(sub, permission) { + return true; + } + } + match &mut *def.effect { + Effect::CastFromZone { + mana_spend_permission: slot @ None, + .. + } + | Effect::GrantCastingPermission { + permission: + CastingPermission::PlayFromExile { + mana_spend_permission: slot @ None, + .. + }, + .. + } => { + *slot = Some(permission); + true + } + _ => false, + } + } + defs.last_mut().is_some_and(|def| walk(def, permission)) +} + /// CR 106.4 + CR 514.2: Recognise mana-retention riders that modify the mana /// produced by the previous clause rather than creating a standalone effect. fn try_parse_mana_retention_rider(text: &str) -> Option { @@ -37087,6 +37271,56 @@ pub(crate) fn parse_effect_chain_ir( } } + // CR 118.14 + CR 609.4b: Any-color / any-type mana rider — "[, and you + // may] spend mana as though it were mana of any color to cast that + // spell" / "Mana of any type can be spent to cast spells this way". It + // applies only to mana spent casting through the PRECEDING grant, so it + // folds onto that grant's `mana_spend_permission` and emits no sibling. Left + // standalone it degraded to a bare board-wide `SpendManaAsAnyColor` + // static that no cast-time payment check ever consults — the granted + // card could not be paid for with off-color mana at all — and its + // "you may" surfaced a spurious optional-effect prompt (Siphon Insight). + // Only folds when the clause it follows is such a grant; anything else + // keeps today's lowering. + if let Some(rider) = try_parse_mana_spend_rider(normalized_text) { + if prior_clause_grants_a_cast_without_mana_spend_permission(builder.clauses()) { + match rider { + ManaSpendRider::Concession(permission) => { + builder + .clause( + normalized_text, + placeholder_parsed_clause("mana_spend_rider_placeholder"), + chunk.boundary_after, + ClauseDisposition::ModifyPrior { + modifier: PriorModifier::ManaSpendPermission(permission), + }, + ) + .push(); + } + // A single-kind concession has no representation: the grant + // stands, the rider is an honest gap — not a "may" prompt and + // not a permission widened to every mana. + ManaSpendRider::SingleKind => { + builder + .clause( + normalized_text, + parsed_clause(Effect::unimplemented( + UNREPRESENTABLE_MANA_SPEND_CONCESSION_GAP, + normalized_text, + )), + chunk.boundary_after, + ClauseDisposition::Emit { + followup: None, + intrinsic: None, + }, + ) + .push(); + } + } + continue; + } + } + // CR 118.9: Alternative-cost rider — "[If you cast a spell // this way,] pay rather than paying its mana cost." // This is a *modifier* on the previous chain entry's `CastFromZone` @@ -39146,8 +39380,8 @@ pub(crate) fn parse_effect_chain_ir( // must be live BEFORE the cast; a delayed trigger firing after it // would be too late. These lower to // `CastFromZone` (the cast restated) or `GenericEffect` (a static - // modification of the grant), and are left exactly as they parsed - // before this change. + // modification of the grant); the "mana of any type" rider now folds + // onto the grant's `mana_spend_permission` (CR 118.14) instead. // * a CONSEQUENCE of the cast — "put a +1/+1 counter on ~" // (Helmut Zemo), "this creature gets +X/+0" (Ogre Battlecaster). // CR 603.7: a separate ability that triggers on the later cast. diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 1875febb93..52a597bb3e 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -26676,7 +26676,7 @@ fn parse_play_from_exile_while_exiled_with_spend_mana_as_any_color_permission() Effect::GrantCastingPermission { permission: CastingPermission::PlayFromExile { duration: Duration::Permanent, - mana_spend_permission: Some(ManaSpendPermission::AnyTypeOrColor), + mana_spend_permission: Some(ManaSpendPermission::AnyColor), .. }, .. @@ -26701,7 +26701,7 @@ fn parse_brainstealer_dragon_play_grant_folds_mana_rider() { Effect::GrantCastingPermission { permission: CastingPermission::PlayFromExile { duration: Duration::Permanent, - mana_spend_permission: Some(ManaSpendPermission::AnyTypeOrColor), + mana_spend_permission: Some(ManaSpendPermission::AnyColor), .. }, target: TargetFilter::TrackedSet { .. }, @@ -50052,10 +50052,8 @@ fn duration_scoped_cast_from_tracked_exile_grant_with_any_color_conjunct() { panic!("expected PlayFromExile permission"); }; assert_eq!(duration, Duration::UntilEndOfTurn); - assert_eq!( - mana_spend_permission, - Some(ManaSpendPermission::AnyTypeOrColor) - ); + // CR 609.4b + CR 106.1a: "any color" is `AnyColor`. + assert_eq!(mana_spend_permission, Some(ManaSpendPermission::AnyColor)); assert_eq!( target, TargetFilter::TrackedSet { @@ -71912,3 +71910,257 @@ fn frost_breath_plural_anaphor_keeps_parent_target() { parent's declared targets" ); } + +/// CR 609.4b: the any-color / any-type mana rider that follows a cast grant is +/// a payment concession on THAT grant, not an effect: it folds onto the grant's +/// `mana_spend_permission` and leaves no `GenericEffect` sibling behind. Both +/// grant shapes and both rider spellings — the ", and you may spend mana as +/// though …" conjunct (Siphon Insight, `CastFromZone`) and the separate +/// "If you cast a spell this way, mana of any type can be spent …" sentence +/// (Bloodsoaked Insight, `GrantCastingPermission { PlayFromExile }`). +#[test] +fn mana_spend_rider_folds_onto_the_preceding_cast_grant() { + let siphon = parse_effect_chain( + "Look at the top two cards of target opponent's library. Exile one of them face down \ + and put the other on the bottom of that library. You may play the exiled card for as \ + long as it remains exiled, and you may spend mana as though it were mana of any color \ + to cast that spell.", + AbilityKind::Spell, + ); + let effects = collect_chain_effects(&siphon); + let stamped: Vec> = effects + .iter() + .filter_map(|effect| match effect { + Effect::CastFromZone { + mana_spend_permission, + .. + } => Some(*mana_spend_permission), + _ => None, + }) + .collect(); + assert_eq!( + stamped, + vec![Some(ManaSpendPermission::AnyColor)], + "\"any color\" rides the play grant as AnyColor; chain: {effects:?}" + ); + assert!( + !effects + .iter() + .any(|effect| matches!(effect, Effect::GenericEffect { .. })), + "the rider emits no sibling static: {effects:?}" + ); + + let bloodsoaked = parse_effect_chain( + "Target opponent exiles the top three cards of their library. Until the end of your \ + next turn, you may play those cards. If you cast a spell this way, mana of any type \ + can be spent to cast it.", + AbilityKind::Spell, + ); + let effects = collect_chain_effects(&bloodsoaked); + let stamped: Vec> = effects + .iter() + .filter_map(|effect| match effect { + Effect::GrantCastingPermission { + permission: + CastingPermission::PlayFromExile { + mana_spend_permission, + .. + }, + .. + } => Some(*mana_spend_permission), + _ => None, + }) + .collect(); + assert_eq!( + stamped, + vec![Some(ManaSpendPermission::AnyTypeOrColor)], + "\"any type\" rides the play grant as AnyTypeOrColor; chain: {effects:?}" + ); + assert!( + !effects + .iter() + .any(|effect| matches!(effect, Effect::GenericEffect { .. })), + "the rider emits no sibling static: {effects:?}" + ); +} + +/// CR 609.4b: the rider modifies the grant it FOLLOWS. Without a cast grant +/// directly before it nothing is folded and the clause keeps its standalone +/// lowering (a pin of the untouched path, green with or without the fold). A +/// concession narrower than "mana" after a grant ("colorless mana as though +/// …", Abstruse Appropriation; "mana from snow sources as though …", Draugr +/// Necromancer) is an honest gap: the grant lowers, the rider is +/// `Unimplemented`, and the grant is never widened to every mana. +#[test] +fn mana_spend_rider_folds_nothing_without_a_matching_grant() { + let without_grant = parse_effect_chain( + "Draw a card. You may spend mana as though it were mana of any color to cast that \ + spell.", + AbilityKind::Spell, + ); + let effects = collect_chain_effects(&without_grant); + assert!( + effects.iter().any(|effect| matches!( + effect, + Effect::GenericEffect { static_abilities, .. } + if static_abilities.iter().any(|s| matches!( + s.mode, + StaticMode::SpendManaAsAnyColor { .. } + )) + )), + "no grant to fold onto: the rider keeps today's standalone lowering: {effects:?}" + ); + + for narrower in [ + "Exile target nonland permanent. You may cast that card for as long as it remains \ + exiled, and you may spend colorless mana as though it were mana of any color to cast \ + that spell.", + "You may cast spells from among cards in exile your opponents own with ice counters on \ + them, and you may spend mana from snow sources as though it were mana of any color to \ + cast those spells.", + ] { + let chain = parse_effect_chain(narrower, AbilityKind::Spell); + let effects = collect_chain_effects(&chain); + let widened = effects.iter().any(|effect| match effect { + Effect::CastFromZone { + mana_spend_permission, + .. + } => mana_spend_permission.is_some(), + Effect::GrantCastingPermission { + permission: + CastingPermission::PlayFromExile { + mana_spend_permission, + .. + }, + .. + } => mana_spend_permission.is_some(), + _ => false, + }); + assert!( + !widened, + "a concession narrower than \"mana\" must not widen the grant: {effects:?}" + ); + assert!( + effects.iter().any(|effect| matches!( + effect, + Effect::Unimplemented { name, .. } + if name == UNREPRESENTABLE_MANA_SPEND_CONCESSION_GAP + )), + "the single-kind rider after a grant is an honest gap, not a static: {effects:?}" + ); + assert!( + !effects + .iter() + .any(|effect| matches!(effect, Effect::GenericEffect { .. })), + "no bare board-wide static survives: {effects:?}" + ); + } +} + +/// CR 609.4b: the rider grammar — subject, concession, and cast object are +/// independent axes. "color" → `AnyColor`, "type" → `AnyTypeOrColor`; the +/// "If you cast a spell this way," gate is dropped (it restates the scope the +/// fold already gives the concession); a single-kind subject is reported, not +/// widened. A pin of the recognizer alone — the fold test above is the +/// discriminator. +#[test] +fn mana_spend_rider_grammar() { + use ManaSpendRider::{Concession, SingleKind}; + for (text, expected) in [ + ( + "you may spend mana as though it were mana of any color to cast that spell", + Some(Concession(ManaSpendPermission::AnyColor)), + ), + ( + "spend mana as though it were mana of any type to cast those spells.", + Some(Concession(ManaSpendPermission::AnyTypeOrColor)), + ), + ( + "Mana of any type can be spent to cast spells this way.", + Some(Concession(ManaSpendPermission::AnyTypeOrColor)), + ), + ( + "Mana of any type can be spent to cast a spell this way", + Some(Concession(ManaSpendPermission::AnyTypeOrColor)), + ), + ( + "If you cast a spell this way, mana of any type can be spent to cast it.", + Some(Concession(ManaSpendPermission::AnyTypeOrColor)), + ), + ( + "Mana of any color can be spent to cast that spell", + Some(Concession(ManaSpendPermission::AnyColor)), + ), + // Narrower than "mana": reported as a single kind. + ( + "you may spend colorless mana as though it were mana of any color to cast that spell", + Some(SingleKind), + ), + ( + "you may spend mana from snow sources as though it were mana of any color to cast \ + those spells", + Some(SingleKind), + ), + // Not a cast rider at all. + ( + "you may spend mana as though it were mana of any color to activate those abilities", + None, + ), + ( + "you may spend mana as though it were mana of any color to cast planeswalker spells", + None, + ), + ] { + assert_eq!(try_parse_mana_spend_rider(text), expected, "{text:?}"); + } +} + +/// CR 118.14 + CR 609.4b: the inline conjunct recognizers accept the printed +/// objects — "… to cast it" after "for as long as it remains exiled" (Court of +/// Locthwain, #8481; Blightwing Bandit; Cruelclaw's Heist) and ", and mana of +/// any type can be spent to cast it" after "this turn" (Reno and Rude). Pre-fix +/// each sentence fell through to the catch-all static and lost its grant. +#[test] +fn inline_any_mana_conjunct_accepts_it_and_the_comma() { + for (text, expected_duration) in [ + ( + "You may play that card for as long as it remains exiled, and mana of any type can \ + be spent to cast it.", + Duration::Permanent, + ), + ( + "You may play the exiled card this turn, and mana of any type can be spent to cast \ + it.", + Duration::UntilEndOfTurn, + ), + ] { + let chain = parse_effect_chain(text, AbilityKind::Spell); + let Effect::GrantCastingPermission { + permission: + CastingPermission::PlayFromExile { + duration, + mana_spend_permission, + .. + }, + .. + } = &*chain.effect + else { + panic!( + "expected a PlayFromExile grant for {text:?}, got {:?}", + chain.effect + ); + }; + assert_eq!(*duration, expected_duration, "{text:?}"); + assert_eq!( + *mana_spend_permission, + Some(ManaSpendPermission::AnyTypeOrColor), + "{text:?}" + ); + assert!( + !collect_chain_effects(&chain) + .iter() + .any(|effect| matches!(effect, Effect::GenericEffect { .. })), + "no sibling static: {text:?}" + ); + } +} diff --git a/crates/engine/src/parser/oracle_ir/effect_chain.rs b/crates/engine/src/parser/oracle_ir/effect_chain.rs index c95d147d7e..f7f3d69bc8 100644 --- a/crates/engine/src/parser/oracle_ir/effect_chain.rs +++ b/crates/engine/src/parser/oracle_ir/effect_chain.rs @@ -15,8 +15,9 @@ use crate::parser::oracle_nom::filter::ChosenColorGrantReference; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AbilityTag, ActivationManaPaymentRestriction, ActivationRestriction, ChoiceType, ControllerRef, - CostReduction, DelayedTriggerCondition, Duration, MultiTargetSpec, OpponentMayScope, - PlayerFilter, QuantityExpr, RoundingMode, SubAbilityLink, TargetChoiceTiming, TargetFilter, + CostReduction, DelayedTriggerCondition, Duration, ManaSpendPermission, MultiTargetSpec, + OpponentMayScope, PlayerFilter, QuantityExpr, RoundingMode, SubAbilityLink, + TargetChoiceTiming, TargetFilter, TargetSelectionMode, UnlessPayModifier, }; use crate::types::keywords::Keyword; @@ -765,6 +766,14 @@ pub(crate) enum PriorModifier { AltCost(AbilityCost), /// CR 106.4: fold a mana-retention expiry onto the prior Mana effect. ManaRetention(ManaExpiry), + /// CR 118.14 + CR 609.4b: fold an any-color / any-type mana concession + /// ("you may spend mana as though it were mana of any color to cast that + /// spell", Siphon Insight; "Mana of any type can be spent to cast a spell + /// this way", Gonti, Night Minister) onto the prior cast grant — a + /// `CastFromZone` or a `GrantCastingPermission { PlayFromExile }` — since + /// it applies only to mana spent casting through that grant. The rider + /// states no permission of its own. + ManaSpendPermission(ManaSpendPermission), /// CR 508.4 / CR 614.1: mark the prior token/copy/zone-change to enter tapped /// and attacking (conditional modifier; carries the gate on the clause's /// `condition`, with the unpatched original stashed in `else_ability`). diff --git a/crates/engine/tests/integration/leading_duration_distribution_7923.rs b/crates/engine/tests/integration/leading_duration_distribution_7923.rs index 2fad51a6e5..19196f550a 100644 --- a/crates/engine/tests/integration/leading_duration_distribution_7923.rs +++ b/crates/engine/tests/integration/leading_duration_distribution_7923.rs @@ -386,12 +386,23 @@ fn xanathar_leading_duration_reaches_governed_chain_links() { at BASE_SHA it is None and the permission is never pruned" ); + // CR 609.4b: the trailing "you may spend mana as though it were mana of + // any color to cast spells this way" is a payment concession on the play + // permission, folded onto it as `mana_spend_permission` — so the + // permission IS the chain leaf, and the duration reaching it is the + // duration reaching the last governed link. let trailing = links .last() - .expect("the trailing mana-spend GenericEffect is the chain leaf"); + .expect("the play permission carrying the mana concession is the chain leaf"); assert!( - matches!(&*trailing.effect, Effect::GenericEffect { .. }), - "chain leaf is the mana-spend GenericEffect, got {:?}", + matches!( + &*trailing.effect, + Effect::CastFromZone { + mana_spend_permission: Some(ManaSpendPermission::AnyColor), + .. + } + ), + "chain leaf is the play permission carrying the any-color concession, got {:?}", trailing.effect ); assert_eq!( @@ -773,10 +784,11 @@ fn you_find_some_prisoners_recovers_mana_rider() { }, "the grant keeps its printed `Until the end of your next turn`" ); - // THE REVERT-FAILING ASSERTION. + // THE REVERT-FAILING ASSERTION. CR 609.4b: "any color" is + // `AnyColor` — the rider is folded by the printed word. assert_eq!( *mana_spend_permission, - Some(ManaSpendPermission::AnyTypeOrColor), + Some(ManaSpendPermission::AnyColor), "CR 611.2a + CR 608.2c: the `spend mana as though …` conjunct must be \ recovered onto the grant; at BASE_SHA it is silently dropped" ); @@ -1357,10 +1369,12 @@ fn leading_duration_merge_cards_unchanged() { &["Legendary".to_string(), "Creature".to_string()], &["Beholder".to_string()], ); + // Four links: the trailing mana rider is folded onto the play permission + // (CR 609.4b), not emitted as a fifth link. assert_eq!( chain(trigger_body(&xan.triggers[0])).len(), - 5, - "Xanathar's chain is the recognizer's own five links — the predicate must not \ + 4, + "Xanathar's chain is the recognizer's own four links — the predicate must not \ re-chunk it" ); let abey = parse_oracle_text(ABEYANCE, "Abeyance", &[], &["Instant".to_string()], &[]); diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index efdb80767e..10eaf89437 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1694,3 +1694,4 @@ mod exploit_ceased_exploiter_lki; mod extra_turn_quantity; mod optional_chain_link_prompt_description; mod ripple_reveal_choice_interaction; +mod siphon_insight_mana_rider; diff --git a/crates/engine/tests/integration/siphon_insight_mana_rider.rs b/crates/engine/tests/integration/siphon_insight_mana_rider.rs new file mode 100644 index 0000000000..c8314033ea --- /dev/null +++ b/crates/engine/tests/integration/siphon_insight_mana_rider.rs @@ -0,0 +1,445 @@ +//! Regression: the any-color / any-type mana rider that follows a cast grant — +//! "You may play the exiled card for as long as it remains exiled, and you may +//! spend mana as though it were mana of any color to cast that spell" (Siphon +//! Insight), "If you cast a spell this way, mana of any type can be spent to +//! cast it" (Bloodsoaked Insight) — is a payment concession that applies only +//! to mana spent casting through the granted permission (CR 118.14 + CR +//! 609.4b), not an effect of its own. +//! +//! Bug: the rider chunk reached the catch-all `SpendManaAsAnyColor` branch of +//! `lower_imperative_clause` and became a sibling `GenericEffect` with a bare +//! board-wide static. Two consequences, both measured before the fix: +//! +//! * its "you may" was promoted to `AbilityDefinition.optional`, so the +//! engine paused resolution with a `WaitingFor::OptionalEffectChoice` after +//! the dig — a prompt for a choice the card never offers; +//! * the granted permission was recorded with `mana_spend_permission: None` +//! and no cast-time payment check consults the transient static, so the +//! exiled card could not be paid for with off-color mana at all +//! (`CastSpell` → `ActionNotAllowed("Cannot pay mana cost")`). +//! +//! Fix: `try_parse_mana_spend_rider` recognizes the rider and, when the clause +//! it follows grants a cast without a concession, emits it as +//! `PriorModifier::ManaSpendPermission` — folded onto that grant's +//! `mana_spend_permission` by `attach_mana_spend_permission_to_prior_cast_grant`. +//! Where the conjunct stays inside the grant's own sentence, the inline +//! recognizers gained the objects they were missing ("… to cast it", the +//! comma before "and mana of any …") — Court of Locthwain, #8481, whose whole +//! sentence used to be swallowed by the catch-all with the grant. +//! The tests here drive the real resolution (`GameScenario` / `GameRunner` / +//! `GameAction`) end to end: no optional prompt, the recorded permission +//! carries the concession, and the granted card is actually cast with +//! off-color lands (or, for the monarch, for free). + +use engine::ai_support::legal_actions; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::{CastingPermission, ManaSpendPermission, TargetRef}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; +use engine::types::ObjectId; + +const SIPHON_INSIGHT: &str = "Look at the top two cards of target opponent's library. Exile one of \ +them face down and put the other on the bottom of that library. You may play the exiled card for as \ +long as it remains exiled, and you may spend mana as though it were mana of any color to cast that \ +spell."; + +const COURT_OF_LOCTHWAIN: &str = "When this enchantment enters, you become the monarch.\n\ +At the beginning of your upkeep, exile the top card of target opponent's library. You may play that \ +card for as long as it remains exiled, and mana of any type can be spent to cast it. If you're the \ +monarch, until end of turn, you may cast a spell from among cards exiled with this enchantment \ +without paying its mana cost."; + +const BLOODSOAKED_INSIGHT: &str = "Target opponent exiles the top three cards of their library. Until \ +the end of your next turn, you may play those cards. If you cast a spell this way, mana of any type \ +can be spent to cast it."; + +/// What the drive saw: every prompt kind it answered, in order, plus the cards +/// the dig offered. The prompt list is the reach guard — the dig step proves +/// the grant resolved — and the discriminator: pre-fix an +/// `OptionalEffectChoice` sat between the dig and the return to priority. +struct Drive { + prompts: Vec<&'static str>, + dug: Vec, +} + +/// Cast `spell` (already free) and resolve it, answering only the prompts the +/// card's own text calls for. Any other prompt fails the test by name. +fn cast_and_resolve(runner: &mut GameRunner, spell: ObjectId) -> Drive { + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("CastSpell accepted"); + settle(runner) +} + +/// Answer the prompts a resolving grant calls for until the stack is empty. +fn settle(runner: &mut GameRunner) -> Drive { + let mut drive = Drive { + prompts: Vec::new(), + dug: Vec::new(), + }; + for _ in 0..40 { + match runner.state().waiting_for.clone() { + WaitingFor::OrderTriggers { triggers, .. } => { + drive.prompts.push("OrderTriggers"); + let order = (0..triggers.len()).collect(); + runner + .act(GameAction::OrderTriggers { order }) + .expect("OrderTriggers accepted"); + } + WaitingFor::TriggerTargetSelection { + target_slots, + selection, + .. + } + | WaitingFor::TargetSelection { + target_slots, + selection, + .. + } => { + drive.prompts.push("TargetSelection"); + let choice = target_slots[selection.current_slot] + .legal_targets + .iter() + .find(|t| **t == TargetRef::Player(P1)) + .cloned(); + runner + .act(GameAction::ChooseTarget { target: choice }) + .expect("ChooseTarget accepted"); + } + WaitingFor::DigChoice { cards, .. } => { + drive.prompts.push("DigChoice"); + drive.dug = cards.clone(); + runner + .act(GameAction::SelectCards { + cards: vec![cards[0]], + }) + .expect("SelectCards accepted"); + } + WaitingFor::Priority { .. } => { + if runner.state().stack.is_empty() { + return drive; + } + drive.prompts.push("Priority"); + runner + .act(GameAction::PassPriority) + .expect("PassPriority accepted"); + } + other => panic!("unexpected prompt while resolving the grant: {other:?}"), + } + } + panic!("the grant never resolved back to an empty stack"); +} + +/// The concession every permission recorded on `card` carries. Exact: a +/// permission without one is reported as `None`, so a half-stamped card fails. +fn recorded_concessions(runner: &GameRunner, card: ObjectId) -> Vec> { + runner.state().objects[&card] + .casting_permissions + .iter() + .map(|permission| match permission { + CastingPermission::ExileWithAltCost { + mana_spend_permission, + .. + } + | CastingPermission::PlayFromExile { + mana_spend_permission, + .. + } => *mana_spend_permission, + other => panic!("unexpected permission recorded on the granted card: {other:?}"), + }) + .collect() +} + +/// Cast the granted `card` from exile paying with the caster's lands and let +/// it resolve. Returns how many of `lands` ended tapped. +fn cast_granted_card(runner: &mut GameRunner, card: ObjectId, lands: &[ObjectId]) -> usize { + assert!( + legal_actions(runner.state()).iter().any( + |action| matches!(action, GameAction::CastSpell { object_id, .. } if *object_id == card) + ), + "the granted card must be offered as a legal cast with off-color lands" + ); + let card_id = runner.state().objects[&card].card_id; + runner + .act(GameAction::CastSpell { + object_id: card, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("the granted card is cast with off-color lands"); + for _ in 0..10 { + match runner.state().waiting_for.clone() { + WaitingFor::Priority { .. } => { + if runner.state().stack.is_empty() { + break; + } + runner + .act(GameAction::PassPriority) + .expect("PassPriority accepted"); + } + other => panic!("unexpected prompt while the granted card resolves: {other:?}"), + } + } + assert_eq!( + runner.state().objects[&card].zone, + Zone::Graveyard, + "the granted sorcery resolved and went to its owner's graveyard" + ); + lands + .iter() + .filter(|land| runner.state().objects[land].tapped) + .count() +} + +/// Siphon Insight: ", and you may spend mana as though it were mana of any +/// color to cast that spell" — the conjunct form, on a `CastFromZone` grant. +#[test] +fn siphon_insights_any_color_rider_is_scoped_to_the_exiled_card_and_asks_nothing() { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + // P1's library, top first: a {G} sorcery over a filler card. + let filler = scenario.add_card_to_library_top(P1, "Filler"); + let green = { + let mut b = scenario.add_spell_to_library_top(P1, "Green Sorcery", false); + b.with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 0, + }); + b.id() + }; + // P0 has only Swamps to pay with. + let swamps = [ + scenario.add_basic_land(P0, ManaColor::Black), + scenario.add_basic_land(P0, ManaColor::Black), + ]; + let siphon = { + let mut b = + scenario.add_spell_to_hand_from_oracle(P0, "Siphon Insight", false, SIPHON_INSIGHT); + b.with_mana_cost(ManaCost::default()); + b.id() + }; + let mut runner = scenario.build(); + + let drive = cast_and_resolve(&mut runner, siphon); + + // DISCRIMINATOR 1: the dig ran (reach guard) and NO optional-effect prompt + // followed it. Pre-fix: ["Priority", "Priority", "DigChoice", + // "OptionalEffectChoice"] — the last one panicked the drive by name. + assert_eq!( + drive.prompts, + vec!["Priority", "Priority", "DigChoice"], + "resolving Siphon Insight asks for the dig choice and nothing else" + ); + assert_eq!( + drive.dug, + vec![green, filler], + "the dig offered P1's top two cards" + ); + assert_eq!(runner.state().objects[&green].zone, Zone::Exile); + assert!(runner.state().objects[&green].face_down, "exiled face down"); + + // DISCRIMINATOR 2: every permission recorded on the exiled card carries the + // printed concession. Pre-fix both read `None`. + assert_eq!( + recorded_concessions(&runner, green), + vec![ + Some(ManaSpendPermission::AnyColor), + Some(ManaSpendPermission::AnyColor) + ], + "\"any color\" rides onto the granted permission as AnyColor" + ); + + // DISCRIMINATOR 3: the {G} card is cast with a Swamp. Pre-fix `CastSpell` + // was refused with "Cannot pay mana cost". + let tapped = cast_granted_card(&mut runner, green, &swamps); + assert_eq!(tapped, 1, "exactly one Swamp paid for {{G}}"); +} + +/// Bloodsoaked Insight: "If you cast a spell this way, mana of any type can be +/// spent to cast it." — the separate-sentence form with the conditional +/// prefix, on a `GrantCastingPermission { PlayFromExile }` grant. +#[test] +fn bloodsoaked_insights_any_type_rider_is_scoped_to_the_exiled_cards() { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + let deep = scenario.add_card_to_library_top(P1, "Deep"); + let third = scenario.add_card_to_library_top(P1, "Third"); + let second = scenario.add_card_to_library_top(P1, "Second"); + let green = { + let mut b = scenario.add_spell_to_library_top(P1, "Green Sorcery", false); + b.with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 0, + }); + b.id() + }; + let swamps = [ + scenario.add_basic_land(P0, ManaColor::Black), + scenario.add_basic_land(P0, ManaColor::Black), + ]; + let bloodsoaked = { + let mut b = scenario.add_spell_to_hand_from_oracle( + P0, + "Bloodsoaked Insight", + false, + BLOODSOAKED_INSIGHT, + ); + b.with_mana_cost(ManaCost::default()); + b.id() + }; + let mut runner = scenario.build(); + + let drive = cast_and_resolve(&mut runner, bloodsoaked); + assert_eq!( + drive.prompts, + vec!["Priority", "Priority"], + "resolving Bloodsoaked Insight asks nothing" + ); + for card in [green, second, third] { + assert_eq!( + runner.state().objects[&card].zone, + Zone::Exile, + "top three exiled" + ); + } + assert_eq!( + runner.state().objects[&deep].zone, + Zone::Library, + "the fourth card stays" + ); + + assert_eq!( + recorded_concessions(&runner, green), + vec![Some(ManaSpendPermission::AnyTypeOrColor)], + "\"any type\" rides onto the granted permission as AnyTypeOrColor" + ); + + let tapped = cast_granted_card(&mut runner, green, &swamps); + assert_eq!(tapped, 1, "exactly one Swamp paid for {{G}}"); +} + +/// Court of Locthwain (#8481): "You may play that card for as long as it +/// remains exiled, and mana of any type can be spent to cast it." — the inline +/// conjunct with "… to cast it". Pre-fix the whole sentence was captured by the +/// catch-all static and no permission existed at all: the exiled card was never +/// offered ("won't even give me the option to pay to cast"). Driven through P0's +/// own upkeep so the trigger, its player target, the exile, the recorded +/// permission and the cast are all real. `monarch` exercises the card's other +/// half — "If you're the monarch, until end of turn, you may cast a spell from +/// among cards exiled with this enchantment without paying its mana cost" — +/// which the report also names ("as the monarch it should be free to cast"). +fn court_of_locthwain(monarch: bool) { + let mut scenario = GameScenario::new_n_player(2, 42); + scenario.at_phase(Phase::PreCombatMain); + let deeper = scenario.add_card_to_library_top(P1, "Deeper"); + let green = { + let mut b = scenario.add_spell_to_library_top(P1, "Green Sorcery", false); + b.with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 0, + }); + b.id() + }; + // P1 draws once while their turn is crossed: a filler on top keeps the + // {G} card as P1's top card at P0's upkeep. P0 draws from filler too. + let p1_draw = scenario.add_card_to_library_top(P1, "P1 Draw"); + for _ in 0..4 { + scenario.add_card_to_library_top(P0, "P0 Filler"); + } + let swamps = [ + scenario.add_basic_land(P0, ManaColor::Black), + scenario.add_basic_land(P0, ManaColor::Black), + ]; + let court = scenario + .add_enchantment_from_oracle(P0, "Court of Locthwain", COURT_OF_LOCTHWAIN) + .id(); + let mut runner = scenario.build(); + if monarch { + // Court's own ETB made its controller the monarch when it entered; + // the enchantment starts on the battlefield here, so set the crown. + runner.state_mut().monarch = Some(P0); + } + + // Cross P1's turn into P0's next upkeep; the auto-advance stops at the + // trigger's target prompt. + runner.advance_to_phase(Phase::Upkeep); + runner.advance_to_phase(Phase::PreCombatMain); + runner.advance_to_phase(Phase::Upkeep); + assert_eq!(runner.state().active_player, P0, "back in P0's turn"); + assert_eq!(runner.state().phase, Phase::Upkeep); + // Reach guard: the upkeep trigger is on the stack (its single legal player + // target, P1, was announced on the way). + assert_eq!( + runner.state().stack.len(), + 1, + "Court's upkeep trigger is waiting to resolve" + ); + assert_eq!(runner.state().stack[0].source_id, court); + + let drive = settle(&mut runner); + // DISCRIMINATOR: no prompt beyond passing priority. Pre-fix the swallowed + // sentence surfaced an `OptionalEffectChoice` here. + assert_eq!( + drive.prompts, + vec!["Priority", "Priority"], + "resolving the trigger asks nothing" + ); + assert_eq!( + runner.state().objects[&p1_draw].zone, + Zone::Hand, + "P1 drew the filler" + ); + assert_eq!( + runner.state().objects[&green].zone, + Zone::Exile, + "top card exiled" + ); + assert_eq!(runner.state().objects[&deeper].zone, Zone::Library); + + // The paid permission carries the concession in both cases; the monarch's + // free cast is a source-linked window (`ExiledBySource`, until end of + // turn) that leaves no permission on the card — it shows in the cast + // below, where no land is tapped. + assert_eq!( + recorded_concessions(&runner, green), + vec![Some(ManaSpendPermission::AnyTypeOrColor)], + "\"any type\" rides onto the granted permission" + ); + // Move to P0's main phase and cast the {G} card. + runner.advance_to_phase(Phase::PreCombatMain); + assert_eq!(runner.state().active_player, P0); + let untapped_before = swamps + .iter() + .filter(|land| !runner.state().objects[land].tapped) + .count(); + assert_eq!( + untapped_before, 2, + "both Swamps untapped after P0's untap step" + ); + let tapped = cast_granted_card(&mut runner, green, &swamps); + if monarch { + assert_eq!(tapped, 0, "the monarch casts for free — no Swamp tapped"); + } else { + assert_eq!(tapped, 1, "exactly one Swamp paid for {{G}}"); + } +} + +#[test] +fn court_of_locthwains_exiled_card_is_castable_with_any_mana() { + court_of_locthwain(false); +} + +#[test] +fn court_of_locthwains_exiled_card_is_free_for_the_monarch() { + court_of_locthwain(true); +}