Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
64 changes: 64 additions & 0 deletions crates/phase-ai/src/mana_colors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! (`subtypes` + `abilities`) so a `GameObject` view and a `CardFace` view share
//! a single implementation, mirroring the `*_parts` pattern in `features`.

use engine::ai_support::CandidateAction;
use engine::game::mana_payment::{land_subtype_to_mana_type, outer_cost_color_demand, ColorDemand};
use engine::game::mana_sources::{activatable_mana_actions_for_player, mana_color_to_type};
use engine::types::ability::{AbilityDefinition, AbilityKind, Effect, ManaProduction};
Expand Down Expand Up @@ -158,6 +159,69 @@ pub(crate) fn tap_strands_demanded_color(
})
}

/// CR 702.51a (Convoke) / CR 702.126a (Improvise) / Waterbend: whether tapping
/// `object_id` for its Colorless convoke-family marker should be rejected
/// because a currently-legal sibling candidate at this exact `ManaPayment`
/// decision lets `object_id` instead pay a colored pip the pending cast still
/// demands, via its own native mana ability.
///
/// This is zero-cost dominance, not a preference: both actions spend the SAME
/// single tap on the SAME permanent, but the native ability can still cover
/// the trailing generic slot once colored demand clears (or pay the colored
/// pip directly), while the Colorless marker can never retroactively produce
/// a stranded color. Companion to `tap_strands_demanded_color` above — that
/// function fixed this same dead-end bug class for land tap-color selection;
/// this is the convoke-family tap-channel-selection variant: nothing
/// previously preferred a permanent's native colored channel over its
/// Colorless convoke-family marker, so a dual-purpose permanent (e.g. an
/// artifact land that also taps for a color) could be spent via the marker
/// first, permanently stranding a colored pip and dead-ending `ManaPayment`.
pub(crate) fn convoke_native_tap_still_demanded(
state: &GameState,
candidates: &[CandidateAction],
object_id: ObjectId,
) -> bool {
let Some(pending_cast) = state.pending_cast.as_deref() else {
return false;
};
let demand = outer_cost_color_demand(&pending_cast.cost);
if demand == [0u32; 5] {
return false;
}
candidates
.iter()
.any(|c| sibling_native_tap_pays_demand(state, &c.action, object_id, demand))
}

fn sibling_native_tap_pays_demand(
state: &GameState,
action: &GameAction,
object_id: ObjectId,
demand: ColorDemand,
) -> bool {
match action {
GameAction::TapLandForMana { selection } => {
selection.source.object_id == object_id
&& color_is_demanded(demand, selection.mana_type)
}
GameAction::ActivateAbility {
source_id,
ability_index,
} if *source_id == object_id => state
.objects
.get(source_id)
.and_then(|obj| obj.abilities.get(*ability_index))
.is_some_and(|ability| {
let mut colors = Vec::new();
if let Effect::Mana { produced, .. } = &*ability.effect {
collect_mana_production_colors(&mut colors, produced);
}
colors.iter().any(|&c| color_is_demanded(demand, c))
}),
_ => false,
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
#[cfg(test)]
mod tests {
use super::*;
Expand Down
229 changes: 228 additions & 1 deletion crates/phase-ai/src/tactical_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ use engine::game::combat::AttackTarget;
use engine::types::ability::{AbilityCondition, Effect, PtValue, TargetFilter, TargetRef};
use engine::types::actions::GameAction;
use engine::types::card_type::CoreType;
use engine::types::game_state::GameState;
use engine::types::game_state::{GameState, WaitingFor};
use engine::types::identifiers::ObjectId;
use engine::types::keywords::Keyword;
use engine::types::mana::ManaType;
use engine::types::phase::Phase;
use engine::types::player::PlayerId;

Expand Down Expand Up @@ -151,6 +152,31 @@ fn assess_candidate(ctx: &PolicyContext<'_>) -> GateDecision {
}
}
GameAction::ChooseTarget { target: None } => GateDecision::Allow,
// CR 702.51a (Convoke) / CR 702.126a (Improvise) / Waterbend: a
// dual-purpose permanent's Colorless convoke-family marker must not be
// taken while a sibling candidate for the SAME object could still pay
// an outstanding colored pip via its native mana ability — spending
// the Colorless marker first permanently strands that pip and
// dead-ends `ManaPayment` (the Metallic Rebuke bug:
// `search::fallback_action`'s "can_cast_object_now has a gap" panic).
// Zero-cost dominance, not a scoring preference: the native ability
// can always still cover the trailing generic slot afterward.
GameAction::TapForConvoke {
object_id,
mana_type: ManaType::Colorless,
} => {
if matches!(ctx.decision.waiting_for, WaitingFor::ManaPayment { .. })
&& crate::mana_colors::convoke_native_tap_still_demanded(
ctx.state,
&ctx.decision.candidates,
*object_id,
)
{
GateDecision::Reject
} else {
GateDecision::Allow
}
}
GameAction::SelectTargets { targets } => {
for target in targets {
if let Some(rejection) = reject_futile_target(ctx, target) {
Expand Down Expand Up @@ -1158,4 +1184,205 @@ mod tests {
assert_ne!(gate_for(8, 7), GateDecision::Reject);
assert_ne!(gate_for(4, 3), GateDecision::Reject);
}

/// Build an Improvise `ManaPayment` decision context: a `TapForConvoke`
/// Colorless candidate for `object_id`, plus whatever sibling candidates
/// the caller supplies for that same dual-purpose permanent.
fn improvise_mana_payment_decision(
sibling_candidates: Vec<CandidateAction>,
object_id: ObjectId,
) -> AiDecisionContext {
let mut candidates = vec![CandidateAction {
action: GameAction::TapForConvoke {
object_id,
mana_type: ManaType::Colorless,
},
metadata: ActionMetadata::for_actor(Some(P0), TacticalClass::Mana),
}];
candidates.extend(sibling_candidates);
AiDecisionContext {
waiting_for: WaitingFor::ManaPayment {
player: P0,
convoke_mode: Some(engine::types::game_state::ConvokeMode::Improvise),
},
candidates,
}
}

fn native_blue_tap_candidate(object_id: ObjectId) -> CandidateAction {
CandidateAction {
action: GameAction::TapLandForMana {
selection: engine::types::mana::ManaSourceSelection {
source: engine::types::identifiers::ObjectIncarnationRef {
object_id,
incarnation: 0,
},
ability_index: None,
mana_type: ManaType::Blue,
output: engine::types::mana::ManaSourceOutput::Concrete(ManaType::Blue),
atomic_combination: None,
restrictions: Vec::new(),
penalty: engine::types::mana::ManaSourcePenalty::None,
taps_for_mana: Vec::new(),
},
},
metadata: ActionMetadata::for_actor(Some(P0), TacticalClass::Mana),
}
}

fn convoke_candidate_ctx<'a>(
state: &'a GameState,
decision: &'a AiDecisionContext,
config: &'a crate::config::AiConfig,
context: &'a AiContext,
) -> PolicyContext<'a> {
PolicyContext {
state,
decision,
candidate: &decision.candidates[0],
ai_player: P0,
config,
context,
cast_facts: None,
search_depth: crate::policies::context::SearchDepth::Root,
}
}

/// CR 702.51a + CR 702.126a: a dual-purpose permanent (an artifact land
/// producing {U} natively) must not be tapped for its Colorless Improvise
/// marker while the pending cast's {U} pip is still outstanding — that
/// would strand the pip and dead-end `ManaPayment` (the Metallic Rebuke
/// bug: crates/phase-ai/src/search.rs's `fallback_action` panic).
#[test]
fn rejects_convoke_colorless_tap_when_native_ability_still_covers_colored_demand() {
let mut state = GameState::new_two_player(42);
state.pending_cast = Some(Box::new(PendingCast::new(
ObjectId(900),
CardId(900),
ResolvedAbility::new(
Effect::Draw {
count: engine::types::ability::QuantityExpr::Fixed { value: 0 },
target: TargetFilter::Controller,
},
Vec::new(),
ObjectId(900),
P0,
),
ManaCost::Cost {
shards: vec![engine::types::mana::ManaCostShard::Blue],
generic: 2,
},
)));
let object_id = ObjectId(901);
let decision =
improvise_mana_payment_decision(vec![native_blue_tap_candidate(object_id)], object_id);
let config = create_config(AiDifficulty::VeryHard, Platform::Wasm);
let context = AiContext::empty(&config.weights);
let ctx = convoke_candidate_ctx(&state, &decision, &config, &context);
assert_eq!(assess_candidate(&ctx), GateDecision::Reject);
}

/// The sibling native-ability tap for the same permanent must stay
/// `Allow` — the fix removes only the redundant Colorless path, not the
/// source's usability.
#[test]
fn allows_native_tap_when_colorless_marker_is_gated() {
let mut state = GameState::new_two_player(42);
state.pending_cast = Some(Box::new(PendingCast::new(
ObjectId(900),
CardId(900),
ResolvedAbility::new(
Effect::Draw {
count: engine::types::ability::QuantityExpr::Fixed { value: 0 },
target: TargetFilter::Controller,
},
Vec::new(),
ObjectId(900),
P0,
),
ManaCost::Cost {
shards: vec![engine::types::mana::ManaCostShard::Blue],
generic: 2,
},
)));
let object_id = ObjectId(901);
let decision =
improvise_mana_payment_decision(vec![native_blue_tap_candidate(object_id)], object_id);
let config = create_config(AiDifficulty::VeryHard, Platform::Wasm);
let context = AiContext::empty(&config.weights);
let ctx = PolicyContext {
state: &state,
decision: &decision,
candidate: &decision.candidates[1],
ai_player: P0,
config: &config,
context: &context,
cast_facts: None,
search_depth: crate::policies::context::SearchDepth::Root,
};
assert_eq!(assess_candidate(&ctx), GateDecision::Allow);
}

/// Once colored demand is satisfied (a generic-only remaining cost), the
/// Colorless marker is fine again — this isn't a blanket ban on
/// Improvise/Convoke for dual-purpose permanents.
#[test]
fn allows_convoke_colorless_tap_once_colored_demand_is_satisfied() {
let mut state = GameState::new_two_player(42);
state.pending_cast = Some(Box::new(PendingCast::new(
ObjectId(900),
CardId(900),
ResolvedAbility::new(
Effect::Draw {
count: engine::types::ability::QuantityExpr::Fixed { value: 0 },
target: TargetFilter::Controller,
},
Vec::new(),
ObjectId(900),
P0,
),
ManaCost::Cost {
shards: Vec::new(),
generic: 3,
},
)));
let object_id = ObjectId(901);
let decision =
improvise_mana_payment_decision(vec![native_blue_tap_candidate(object_id)], object_id);
let config = create_config(AiDifficulty::VeryHard, Platform::Wasm);
let context = AiContext::empty(&config.weights);
let ctx = convoke_candidate_ctx(&state, &decision, &config, &context);
assert_eq!(assess_candidate(&ctx), GateDecision::Allow);
}

/// A permanent with no sibling native colored option (a plain
/// non-mana-producing artifact) is unaffected by the gate — it's scoped
/// to true tap-channel dominance, not all Colorless taps.
#[test]
fn allows_convoke_colorless_tap_on_permanent_with_no_native_colored_option() {
let mut state = GameState::new_two_player(42);
state.pending_cast = Some(Box::new(PendingCast::new(
ObjectId(900),
CardId(900),
ResolvedAbility::new(
Effect::Draw {
count: engine::types::ability::QuantityExpr::Fixed { value: 0 },
target: TargetFilter::Controller,
},
Vec::new(),
ObjectId(900),
P0,
),
ManaCost::Cost {
shards: vec![engine::types::mana::ManaCostShard::Blue],
generic: 2,
},
)));
let object_id = ObjectId(901);
let decision = improvise_mana_payment_decision(Vec::new(), object_id);
let config = create_config(AiDifficulty::VeryHard, Platform::Wasm);
let context = AiContext::empty(&config.weights);
let ctx = convoke_candidate_ctx(&state, &decision, &config, &context);
assert_eq!(assess_candidate(&ctx), GateDecision::Allow);
}
}
Loading