diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index aea648333b..855f29d1ea 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -16,7 +16,7 @@ use crate::types::card::LayoutKind; use crate::types::card_type::CoreType; use crate::types::counter::CounterMatch; use crate::types::game_state::{ - CastOfferKind, CastPaymentMode, CompanionDeclaration, ConvokeMode, CostResume, + CastOfferKind, CastPaymentMode, CastingVariant, CompanionDeclaration, ConvokeMode, CostResume, CounterCostChoice, CounterMoveChoice, CounterRemoveChoice, GameState, MulliganDecisionPhase, PayCostKind, PayableResource, PendingMulliganAction, TargetSelectionSlot, WaitingFor, }; @@ -4815,13 +4815,35 @@ fn mana_payment_actions( convoke_mode: Option, ) -> Vec { let mut actions = mana_tap_actions(state, player); + let has_delve = state.pending_cast.as_ref().is_some_and(|pending| { + crate::game::casting::spell_has_delve_payment_for( + state, + player, + pending.object_id, + pending.casting_variant == CastingVariant::Fuse, + ) + }); // Always include PassPriority to finalize payment actions.push(candidate( GameAction::PassPriority, TacticalClass::Pass, Some(player), )); - if let Some(mode) = convoke_mode { + if has_delve { + for (&obj_id, obj) in &state.objects { + if obj.is_delve_eligible(player) { + actions.push(candidate( + GameAction::TapForConvoke { + object_id: obj_id, + mana_type: crate::types::mana::ManaType::Colorless, + }, + TacticalClass::Mana, + Some(player), + )); + } + } + } + if let Some(mode) = convoke_mode.filter(|mode| *mode != ConvokeMode::Delve) { // CR 702.51a + CR 302.6: Summoning sickness does not restrict tapping for convoke. // CR 702.51a: a Convoke tap reduces the cost by {1} (a Colorless marker) or by one // mana of the creature's color (a colored marker, which pays ONLY a matching colored @@ -4834,9 +4856,24 @@ fn mana_payment_actions( crate::types::mana::ManaCost::Cost { shards, .. } => Some(shards.as_slice()), _ => None, }); - if mode == ConvokeMode::Delve { - for (&obj_id, obj) in &state.objects { - if obj.is_delve_eligible(player) { + // Non-Delve convoke/improvise/waterbend taps come from the battlefield + // only; the eligibility helpers all require `zone == Battlefield`, so + // iterating `state.battlefield` (rather than every object in the game) + // is behavior-preserving and avoids scanning hand/library/graveyard + // objects on go-wide token boards. + for &obj_id in &state.battlefield { + let Some(obj) = state.objects.get(&obj_id) else { + continue; + }; + // CR 701.26a + CR 508.1f: a "can't become tapped" creature can't be + // tapped for convoke/improvise/waterbend (all tap the creature to + // pay). Delve (graveyard exile above) never taps, so it's exempt. + if crate::game::restrictions::object_cant_tap(state, obj_id) { + continue; + } + match mode { + ConvokeMode::Waterbend if obj.is_waterbend_eligible(player) => { + // Waterbend: always colorless actions.push(candidate( GameAction::TapForConvoke { object_id: obj_id, @@ -4846,79 +4883,52 @@ fn mana_payment_actions( Some(player), )); } - } - } else { - // Non-Delve convoke/improvise/waterbend taps come from the - // battlefield only; the eligibility helpers all require - // `zone == Battlefield`, so iterating `state.battlefield` (rather - // than every object in the game) is behavior-preserving and avoids - // scanning hand/library/graveyard objects on go-wide token boards. - for &obj_id in &state.battlefield { - let Some(obj) = state.objects.get(&obj_id) else { - continue; - }; - // CR 701.26a + CR 508.1f: a "can't become tapped" creature can't be - // tapped for convoke/improvise/waterbend (all tap the creature to - // pay). Delve (graveyard exile above) never taps, so it's exempt. - if crate::game::restrictions::object_cant_tap(state, obj_id) { - continue; + ConvokeMode::Improvise if obj.is_improvise_eligible(player) => { + // CR 702.126a: Improvise pays generic mana — always colorless. + actions.push(candidate( + GameAction::TapForConvoke { + object_id: obj_id, + mana_type: crate::types::mana::ManaType::Colorless, + }, + TacticalClass::Mana, + Some(player), + )); } - match mode { - ConvokeMode::Waterbend if obj.is_waterbend_eligible(player) => { - // Waterbend: always colorless - actions.push(candidate( - GameAction::TapForConvoke { - object_id: obj_id, - mana_type: crate::types::mana::ManaType::Colorless, - }, - TacticalClass::Mana, - Some(player), - )); - } - ConvokeMode::Improvise if obj.is_improvise_eligible(player) => { - // CR 702.126a: Improvise pays generic mana — always colorless. - actions.push(candidate( - GameAction::TapForConvoke { - object_id: obj_id, - mana_type: crate::types::mana::ManaType::Colorless, - }, - TacticalClass::Mana, - Some(player), - )); - } - ConvokeMode::Convoke if obj.is_convoke_eligible(player) => { - // CR 702.51a: Colorless (for generic) always available + ConvokeMode::Convoke if obj.is_convoke_eligible(player) => { + // CR 702.51a: Colorless (for generic) always available + actions.push(candidate( + GameAction::TapForConvoke { + object_id: obj_id, + mana_type: crate::types::mana::ManaType::Colorless, + }, + TacticalClass::Mana, + Some(player), + )); + // CR 702.51a: one colored tap per color the creature has — but only + // colors the cost can actually use. A colored convoke marker pays only a + // matching colored pip, so a color absent from the cost is a wasted tap. + // `contributes_to` covers hybrid/Phyrexian/two-brid pips. When the cost is + // unavailable, offer every color rather than risk pruning a useful option. + for color in &obj.color { + if let Some(shards) = convoke_cost_shards { + if !shards.iter().any(|shard| shard.contributes_to(*color)) { + continue; + } + } actions.push(candidate( GameAction::TapForConvoke { object_id: obj_id, - mana_type: crate::types::mana::ManaType::Colorless, + mana_type: mana_sources::mana_color_to_type(color), }, TacticalClass::Mana, Some(player), )); - // CR 702.51a: one colored tap per color the creature has — but only - // colors the cost can actually use. A colored convoke marker pays only a - // matching colored pip, so a color absent from the cost is a wasted tap. - // `contributes_to` covers hybrid/Phyrexian/two-brid pips. When the cost is - // unavailable, offer every color rather than risk pruning a useful option. - for color in &obj.color { - if let Some(shards) = convoke_cost_shards { - if !shards.iter().any(|shard| shard.contributes_to(*color)) { - continue; - } - } - actions.push(candidate( - GameAction::TapForConvoke { - object_id: obj_id, - mana_type: mana_sources::mana_color_to_type(color), - }, - TacticalClass::Mana, - Some(player), - )); - } } - _ => {} } + ConvokeMode::Convoke + | ConvokeMode::Improvise + | ConvokeMode::Waterbend + | ConvokeMode::Delve => {} } } } diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 2000561586..0b3fdc4ec1 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -6720,6 +6720,17 @@ mod tests { ); } set_dummy_pending_cast(&mut state); + let pending_spell = state + .pending_cast + .as_ref() + .expect("dummy pending cast exists") + .object_id; + state + .objects + .get_mut(&pending_spell) + .expect("dummy spell exists") + .keywords + .push(Keyword::Delve); state.waiting_for = WaitingFor::ManaPayment { player: PlayerId(0), convoke_mode: Some(ConvokeMode::Delve), diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 0e112b4a4f..6ec1780052 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -14646,6 +14646,21 @@ pub(super) fn spell_tap_payment_mode_for( } } +/// CR 702.66a: Delve is an independent generic-payment permission. It composes +/// with a spell's primary tap-payment mode (for example, Hogaak's Convoke), so +/// callers must query it separately rather than treating `ConvokeMode` as a +/// mutually exclusive keyword selection. +pub(crate) fn spell_has_delve_payment_for( + state: &GameState, + player: PlayerId, + source_id: ObjectId, + fused: bool, +) -> bool { + effective_spell_keywords_for(state, player, source_id, fused) + .iter() + .any(|keyword| matches!(keyword, Keyword::Delve)) +} + /// CR 601.2c + CR 601.2f: Target selection may precede locking the final /// mana obligation. Return true only when none of the production cost axes can /// still change the amount or the sources available before payment. @@ -14799,13 +14814,25 @@ fn can_pay_with_spell_tap_payments( else { return false; }; - can_pay_with_tap_payment_mode(state, player, mode, cost, ctx, permissions) + let fused = state.pending_cast.as_ref().is_some_and(|pending| { + pending.object_id == source_id && pending.casting_variant == CastingVariant::Fuse + }); + can_pay_with_tap_payment_mode( + state, + player, + mode, + spell_has_delve_payment_for(state, player, source_id, fused), + cost, + ctx, + permissions, + ) } fn can_pay_with_tap_payment_mode( state: &GameState, player: PlayerId, mode: ConvokeMode, + has_delve: bool, cost: &crate::types::mana::ManaCost, ctx: Option<&PaymentContext<'_>>, permissions: crate::types::mana::CostPermissionContext, @@ -14814,12 +14841,26 @@ fn can_pay_with_tap_payment_mode( return false; }; + let mut payment_pool = player_data.mana_pool.clone(); + if has_delve && mode != ConvokeMode::Delve { + // CR 702.66a: Delve's generic-only contributions compose with the + // primary Convoke/Improvise/Waterbend payment channel. + for (&object_id, obj) in &state.objects { + if obj.is_delve_eligible(player) { + payment_pool.add(crate::types::mana::ManaUnit::convoke_payment( + crate::types::mana::ManaType::Colorless, + object_id, + )); + } + } + } + // CR 601.2h: This is an affordability preview only. The real payment still // flows through ManaPayment and the shared mana-payment algorithm. match mode { ConvokeMode::Improvise => { // CR 702.126a: Improvise lets players tap untapped artifacts to pay generic mana. - let mut pool = player_data.mana_pool.clone(); + let mut pool = payment_pool; for (&object_id, obj) in &state.objects { if obj.is_improvise_eligible(player) { pool.add(crate::types::mana::ManaUnit::convoke_payment( @@ -14831,7 +14872,7 @@ fn can_pay_with_tap_payment_mode( mana_payment::can_pay_for_spell(&pool, cost, ctx, permissions) } ConvokeMode::Waterbend => { - let mut pool = player_data.mana_pool.clone(); + let mut pool = payment_pool; for (&object_id, obj) in &state.objects { if obj.is_waterbend_eligible(player) { pool.add(crate::types::mana::ManaUnit::new( @@ -14863,13 +14904,13 @@ fn can_pay_with_tap_payment_mode( Some(choices) }) .collect::>(); - can_pay_with_convoke_options(&player_data.mana_pool, cost, ctx, permissions, &options) + can_pay_with_convoke_options(&payment_pool, cost, ctx, permissions, &options) } ConvokeMode::Delve => { // CR 702.66a: each card in the caster's graveyard can be exiled to pay // one generic mana. Model each as a generic-only colorless unit, exactly // like Improvise, so a spell castable only with delve is offered. - let mut pool = player_data.mana_pool.clone(); + let mut pool = payment_pool; for (&object_id, obj) in &state.objects { if obj.is_delve_eligible(player) { pool.add(crate::types::mana::ManaUnit::convoke_payment( @@ -15211,7 +15252,18 @@ fn feasibly_payable_with_tap_payment_mode_in_context( player_can_spend_as_any_color_for_payment(simulated, player, Some(source_id), ctx); let permissions = super::static_abilities::build_cost_permission_context(simulated, player, any_color); - can_pay_with_tap_payment_mode(simulated, player, tap_payment_mode, cost, ctx, permissions) + let fused = simulated.pending_cast.as_ref().is_some_and(|pending| { + pending.object_id == source_id && pending.casting_variant == CastingVariant::Fuse + }); + can_pay_with_tap_payment_mode( + simulated, + player, + tap_payment_mode, + spell_has_delve_payment_for(simulated, player, source_id, fused), + cost, + ctx, + permissions, + ) } /// Castability-gate feasibility predicate. Returns true if `player` could pay diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index be3da65273..451c1983e0 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -8788,6 +8788,12 @@ pub(super) fn pay_and_push_adventure( object_id, casting_variant == CastingVariant::Fuse, ); + let has_delve = super::casting::spell_has_delve_payment_for( + state, + player, + object_id, + casting_variant == CastingVariant::Fuse, + ); // Gate on eligible creatures/artifacts being present. let convoke_mode = convoke_mode.filter(|mode| { state.objects.values().any(|o| match mode { @@ -8796,7 +8802,7 @@ pub(super) fn pay_and_push_adventure( ConvokeMode::Improvise => o.is_improvise_eligible(player), // CR 702.66a: delve needs at least one eligible card in the caster's graveyard. ConvokeMode::Delve => o.is_delve_eligible(player), - }) + }) || (has_delve && state.objects.values().any(|o| o.is_delve_eligible(player))) }); // Enter the payment step if cost needs player input (X), convoke/waterbend is active, @@ -11820,7 +11826,12 @@ pub(super) fn max_x_value_excluding( // only generic mana by exiling cards from the caster's graveyard. Unlike // tap-payment keywords, this is an additional graveyard-card channel rather // than an alternative use of battlefield permanents. - let delve_capacity = if matches!(tap_payment_mode, Some(ConvokeMode::Delve)) { + let delve_capacity = if object_id.is_some_and(|oid| { + let fused = state.pending_cast.as_ref().is_some_and(|pending| { + pending.object_id == oid && pending.casting_variant == CastingVariant::Fuse + }); + super::casting::spell_has_delve_payment_for(state, player, oid, fused) + }) { state .objects .iter() diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index e5f8450fed..f484c7e900 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -11,9 +11,9 @@ use crate::types::actions::{ use crate::types::events::{BendingType, ContestRound, GameEvent, ManaTapState, PlayerActionKind}; use crate::types::game_state::{ ActionResult, AssistState, AutoMayChoice, AutoPassMode, AutoPassRequest, CastOfferKind, - ConvokeMode, CostResume, GameState, LandPlayRecord, LoopDetectionMode, ManaAbilityResume, - MayTriggerAutoChoiceKey, PayCostKind, PendingCostMoveResume, RetargetScope, StackEntry, - StackEntryKind, WaitingFor, + CastingVariant, ConvokeMode, CostResume, GameState, LandPlayRecord, LoopDetectionMode, + ManaAbilityResume, MayTriggerAutoChoiceKey, PayCostKind, PendingCostMoveResume, RetargetScope, + StackEntry, StackEntryKind, WaitingFor, }; use crate::types::identifiers::{CardId, DelayedTriggerOrigin, ObjectId, ObjectIncarnationRef}; use crate::types::match_config::MatchType; @@ -6169,9 +6169,17 @@ pub(super) fn resume_delve_mana_payment(state: &mut GameState) -> WaitingFor { fuel_id, ), ); + let convoke_mode = state.pending_cast.as_ref().and_then(|pending| { + super::casting::spell_tap_payment_mode_for( + state, + player, + pending.object_id, + pending.casting_variant == CastingVariant::Fuse, + ) + }); WaitingFor::ManaPayment { player, - convoke_mode: Some(ConvokeMode::Delve), + convoke_mode: convoke_mode.or(Some(ConvokeMode::Delve)), } } @@ -9477,6 +9485,54 @@ fn apply_action( convoke_mode, } } + // CR 702.66a: Delve composes with a primary tap-payment keyword (for + // example, Hogaak's Convoke). Handle the graveyard contribution first so + // its object is never rejected by the battlefield-tap arm below. + ( + WaitingFor::ManaPayment { player, .. }, + GameAction::TapForConvoke { + object_id, + mana_type, + }, + ) if state.objects.get(&object_id).is_some_and(|object| object.is_delve_eligible(*player)) + && state.pending_cast.as_ref().is_some_and(|pending| { + super::casting::spell_has_delve_payment_for( + state, + *player, + pending.object_id, + pending.casting_variant == CastingVariant::Fuse, + ) + }) => { + let player = *player; + if mana_type != crate::types::mana::ManaType::Colorless { + return Err(EngineError::ActionNotAllowed( + "Delve can only pay generic mana".to_string(), + )); + } + let spell_id = state + .pending_cast + .as_ref() + .map(|pending| pending.object_id) + .ok_or_else(|| { + EngineError::InvalidAction("No pending cast for delve".to_string()) + })?; + state.pending_cost_move_resume = Some(PendingCostMoveResume::DelveManaPayment { + player, + fuel_id: object_id, + }); + match zone_pipeline::move_object( + state, + ZoneMoveRequest::cost(object_id, Zone::Exile, spell_id) + .track_exiled_by_source(), + &mut events, + ) { + ZoneMoveResult::Done => resume_delve_mana_payment(state), + ZoneMoveResult::NeedsChoice(_) => state.waiting_for.clone(), + ZoneMoveResult::NeedsAuraAttachmentChoice => { + unreachable!("a delve cost move to exile cannot require an Aura attachment") + } + } + } // CR 702.51a / Waterbend: Tap a creature or artifact to pay mana. // CR 702.51a + CR 302.6: Convoke taps creatures to pay mana; summoning sickness // (CR 302.6) is not checked because convoke does not use the tap activated-ability mechanism. @@ -9596,64 +9652,6 @@ fn apply_action( convoke_mode: Some(mode), } } - // CR 702.66a: Delve — exile a card from the caster's graveyard to pay one - // generic mana. Unlike convoke/improvise (which tap a permanent), the - // source is a graveyard card that is exiled. The contribution is a - // generic-only colorless marker (like Improvise) that can't leak into the - // pool. - ( - WaitingFor::ManaPayment { - player, - convoke_mode: Some(ConvokeMode::Delve), - }, - GameAction::TapForConvoke { - object_id, - mana_type, - }, - ) => { - let player = *player; - if mana_type != crate::types::mana::ManaType::Colorless { - return Err(EngineError::ActionNotAllowed( - "Delve can only pay generic mana".to_string(), - )); - } - let eligible = state - .objects - .get(&object_id) - .is_some_and(|o| o.is_delve_eligible(player)); - if !eligible { - return Err(EngineError::ActionNotAllowed( - "Can only delve a card from your own graveyard".to_string(), - )); - } - let spell_id = state - .pending_cast - .as_ref() - .map(|pending| pending.object_id) - .ok_or_else(|| { - EngineError::InvalidAction("No pending cast for delve".to_string()) - })?; - state.pending_cost_move_resume = Some(PendingCostMoveResume::DelveManaPayment { - player, - fuel_id: object_id, - }); - // CR 702.66a + CR 614.1 + CR 616.1: The cost move must consult Moved - // replacements. `track_exiled_by_source` carries - // `ExileLinkSpec { duration: None, tracking: TrackBySource }`, so the - // delivery tail links only fuel that actually reaches exile. - match zone_pipeline::move_object( - state, - ZoneMoveRequest::cost(object_id, Zone::Exile, spell_id) - .track_exiled_by_source(), - &mut events, - ) { - ZoneMoveResult::Done => resume_delve_mana_payment(state), - ZoneMoveResult::NeedsChoice(_) => state.waiting_for.clone(), - ZoneMoveResult::NeedsAuraAttachmentChoice => { - unreachable!("a delve cost move to exile cannot require an Aura attachment") - } - } - } (WaitingFor::MulliganDecision { .. }, GameAction::MulliganDecision { choice }) => { // CR 103.5 + 103.5b: `actor` is already authorized as a member of // `pending` by `check_actor_authorization`. The mulligan module @@ -16428,7 +16426,7 @@ mod stage2_injector_tests { // // SET PRESERVATION: unchanged. Upstream adds no line matching the needle to this file and // neither does this branch — total still 37, partition still 5/7/25. - "game/engine.rs:12006".to_string(), + "game/engine.rs:12004".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index cb4edc9d81..9d9a8d9db2 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -1839,6 +1839,14 @@ fn mana_payment_direct_actions( player: PlayerId, convoke_mode: Option, ) -> Result, InteractionReasonCode> { + let has_delve = state.pending_cast.as_ref().is_some_and(|pending| { + super::casting::spell_has_delve_payment_for( + state, + player, + pending.object_id, + pending.casting_variant == CastingVariant::Fuse, + ) + }); let activation_upper_bound = state .battlefield .iter() @@ -1867,11 +1875,7 @@ fn mana_payment_direct_actions( .unwrap_or_default(); let convoke_upper_bound = match convoke_mode { None => 0, - Some(ConvokeMode::Delve) => state - .objects - .values() - .filter(|object| object.is_delve_eligible(player)) - .count(), + Some(ConvokeMode::Delve) => 0, Some(mode) => state .battlefield .iter() @@ -1889,11 +1893,21 @@ fn mana_payment_direct_actions( .try_fold(0usize, |count, choices| count.checked_add(choices)) .ok_or(InteractionReasonCode::PayloadTooLarge)?, }; + let delve_upper_bound = if has_delve { + state + .objects + .values() + .filter(|object| object.is_delve_eligible(player)) + .count() + } else { + 0 + }; let total_upper_bound = actions .len() .checked_add(tapped_for_mana.len()) .and_then(|count| count.checked_add(pool.mana_pool.mana.len())) .and_then(|count| count.checked_add(convoke_upper_bound)) + .and_then(|count| count.checked_add(delve_upper_bound)) .and_then(|count| count.checked_add(2)) .ok_or(InteractionReasonCode::PayloadTooLarge)?; if total_upper_bound > MAX_INTERACTION_LIST_LEN { @@ -1926,18 +1940,18 @@ fn mana_payment_direct_actions( } }), ); + if has_delve { + actions.extend(state.objects.values().filter_map(|object| { + object + .is_delve_eligible(player) + .then_some(GameAction::TapForConvoke { + object_id: object.id, + mana_type: ManaType::Colorless, + }) + })); + } match convoke_mode { - None => {} - Some(ConvokeMode::Delve) => { - actions.extend(state.objects.values().filter_map(|object| { - object - .is_delve_eligible(player) - .then_some(GameAction::TapForConvoke { - object_id: object.id, - mana_type: ManaType::Colorless, - }) - })); - } + None | Some(ConvokeMode::Delve) => {} Some(mode) => { let cost_shards = state .pending_cast diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index a1b10dface..1ee37cc937 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -2483,18 +2483,18 @@ impl<'a> SpellCast<'a> { // the pool can't cover it, `PassPriority` errors and the `.expect` // below fails loudly — fund the pool in the scenario. WaitingFor::ManaPayment { convoke_mode, .. } => { - if matches!(convoke_mode, Some(ConvokeMode::Delve)) { - for &card in &delve_with { - act_collect( - runner, - GameAction::TapForConvoke { - object_id: card, - mana_type: ManaType::Colorless, - }, - &mut events, - )?; - } - } else { + let only_delve = matches!(convoke_mode, Some(ConvokeMode::Delve)); + for &card in &delve_with { + act_collect( + runner, + GameAction::TapForConvoke { + object_id: card, + mana_type: ManaType::Colorless, + }, + &mut events, + )?; + } + if !only_delve { for &creature in &convoke_with { // CR 702.51b: pay one mana of the creature's color, or // colorless toward the generic portion of the cost. diff --git a/crates/engine/tests/integration/hogaak_cant_spend_mana_1095.rs b/crates/engine/tests/integration/hogaak_cant_spend_mana_1095.rs index 5f8fabc367..7cad583a3d 100644 --- a/crates/engine/tests/integration/hogaak_cant_spend_mana_1095.rs +++ b/crates/engine/tests/integration/hogaak_cant_spend_mana_1095.rs @@ -12,7 +12,7 @@ use engine::game::scenario::{GameScenario, P0}; use engine::types::identifiers::ObjectId; -use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; use engine::types::phase::Phase; use engine::types::zones::Zone; @@ -103,3 +103,70 @@ fn pool_mana_alone_cannot_pay_hogaak() { "no pool mana may be spent on a cast that cannot legally be paid" ); } + +/// Hogaak's real `{5}{B/G}{B/G}` cost is payable from its graveyard only when +/// Convoke supplies the two green hybrid pips and Delve exiles five graveyard +/// cards for the generic component. The payment permissions compose; no pool +/// mana is available or spent. +#[test] +fn hogaak_combines_convoke_and_delve_from_graveyard() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let hogaak = scenario + .add_creature_to_graveyard(P0, "Hogaak, Arisen Necropolis", 8, 8) + .from_oracle_text(HOGAAK_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::BlackGreen, ManaCostShard::BlackGreen], + generic: 5, + }) + .id(); + let convoker_a = scenario.add_creature(P0, "Green Convoker A", 1, 1).id(); + let convoker_b = scenario.add_creature(P0, "Green Convoker B", 1, 1).id(); + let delve_fuel: Vec = (0..5) + .map(|index| { + scenario + .add_spell_to_graveyard(P0, &format!("Delve Fuel {index}"), true) + .id() + }) + .collect(); + + let mut runner = scenario.build(); + for convoker in [convoker_a, convoker_b] { + runner + .state_mut() + .objects + .get_mut(&convoker) + .expect("convoker exists") + .color + .push(ManaColor::Green); + } + + let outcome = runner + .cast(hogaak) + .delve_with(&delve_fuel) + .convoke_with(&[convoker_a, convoker_b]) + .resolve(); + let state = outcome.state(); + + assert_eq!( + state.objects[&hogaak].zone, + Zone::Battlefield, + "Hogaak must be cast from the graveyard when its real cost is fully covered" + ); + assert!( + delve_fuel + .iter() + .all(|fuel| state.objects[fuel].zone == Zone::Exile), + "Delve must exile exactly the selected graveyard cards" + ); + assert!( + [convoker_a, convoker_b] + .iter() + .all(|convoker| state.objects[convoker].tapped), + "Convoke must tap both green creatures for Hogaak's hybrid pips" + ); + assert!( + !state.objects[&hogaak].mana_spent_to_cast, + "Hogaak's restriction forbids spending ordinary mana" + ); +}