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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 78 additions & 68 deletions crates/engine/src/ai_support/candidates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -4815,13 +4815,35 @@ fn mana_payment_actions(
convoke_mode: Option<ConvokeMode>,
) -> Vec<CandidateAction> {
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),
));
}
}
}
Comment on lines +4818 to +4845

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add verified Delve CR annotations to both action generators.

Both blocks decide Delve legality and emit Delve payment actions. Neither block includes the required CR <number>: <description> annotation. Use a verified CR 702.66a annotation that describes exiling a graveyard card to pay one generic mana. (media.wizards.com)

  • crates/engine/src/ai_support/candidates.rs#L4818-L4845: annotate independent Delve candidate detection and emission.
  • crates/engine/src/game/interaction.rs#L1842-L1954: annotate independent Delve direct-action detection and emission.

As per path instructions: "rules-touching code with no verified CR <number>: <description> annotation" is a finding.

📍 Affects 2 files
  • crates/engine/src/ai_support/candidates.rs#L4818-L4845 (this comment)
  • crates/engine/src/game/interaction.rs#L1842-L1954
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/ai_support/candidates.rs` around lines 4818 - 4845, Add a
verified “CR 702.66a” annotation describing exiling a graveyard card to pay one
generic mana to both independent Delve logic sites: annotate the detection and
payment-action emission around `has_delve` and `GameAction::TapForConvoke` in
`crates/engine/src/ai_support/candidates.rs` lines 4818-4845, and the
corresponding direct-action detection and emission in
`crates/engine/src/game/interaction.rs` lines 1842-1954. Ensure each site has
its own annotation.

Source: Path instructions

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
Expand All @@ -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,
Expand All @@ -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 => {}
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions crates/engine/src/ai_support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
64 changes: 58 additions & 6 deletions crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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,
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -14863,13 +14904,13 @@ fn can_pay_with_tap_payment_mode(
Some(choices)
})
.collect::<Vec<_>>();
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(
Expand Down Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading