From 3f2dc5a2d6a29cddb4a43febea5a3fa70c4ea5dd Mon Sep 17 00:00:00 2001 From: Whovencroft <6.60056e+06+Whovencroft@users.noreply.github.com> Date: Sat, 23 May 2026 05:58:20 +0000 Subject: [PATCH] feat: wire AbilityCost::OneOf payment for activation costs (CR 118.12a) Wire the engine to handle disjunctive activation costs ({T} or {mana}) by surfacing the choice to the player before cost payment: - Add WaitingFor::ActivationCostOneOfChoice (game_state.rs) - Add GameAction::ChooseActivationCostBranch (actions.rs) - Add detour in handle_activate_ability (casting.rs) - Add handler handle_activation_cost_one_of_choice (casting_costs.rs) - Add engine dispatch (engine.rs, engine_casting.rs) - Add AI candidate generation (candidates.rs) - Add scenario debug label (scenario.rs) The chosen branch replaces the OneOf node in the Composite cost tree, then finish_pending_cost_or_cast resumes the standard activation flow. Unlocks Crystal Shard, Pearl Shard, Heartwood Shard, Skeleton Shard. Model: claude-sonnet-4-20250514 --- .gitignore | 2 ++ crates/engine/src/ai_support/candidates.rs | 10 ++++++ crates/engine/src/game/casting.rs | 24 ++++++++++++- crates/engine/src/game/casting_costs.rs | 40 ++++++++++++++++++++++ crates/engine/src/game/engine.rs | 23 +++++++++++++ crates/engine/src/game/engine_casting.rs | 13 ++++++- crates/engine/src/game/scenario.rs | 1 + crates/engine/src/types/actions.rs | 8 +++-- crates/engine/src/types/game_state.rs | 9 +++++ 9 files changed, 126 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ecdbb0b5b4..7c52f0daf8 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,8 @@ replay_dtypes.py # Scratch planning files parser-batch-plan.md parser-fallback-plan.md +AI-CONTRIBUTOR-TODO.md +working-todo.md tmp/ # External reference / scratch diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index d4e2dc671e..edcc08d8b5 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -1242,6 +1242,16 @@ pub fn candidate_actions_broad(state: &GameState) -> Vec { permanents, .. } => bounded_select_card_candidates(*player, permanents, [*count]), + // CR 118.12a: AI selects a branch of a disjunctive activation cost. + WaitingFor::ActivationCostOneOfChoice { player, costs, .. } => (0..costs.len()) + .map(|i| { + candidate( + GameAction::ChooseActivationCostBranch { index: i }, + TacticalClass::Selection, + Some(*player), + ) + }) + .collect(), WaitingFor::ReturnToHandForCost { player, count, diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index a1a41691fe..b663755f11 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -6454,6 +6454,14 @@ pub(crate) fn find_eligible_exile_for_cost_targets( } } +fn find_one_of_cost(cost: &AbilityCost) -> Option<&Vec> { + match cost { + AbilityCost::OneOf { costs } => Some(costs), + AbilityCost::Composite { costs } => costs.iter().find_map(find_one_of_cost), + _ => None, + } +} + fn find_return_to_hand_cost(cost: &AbilityCost) -> Option<(u32, Option<&TargetFilter>)> { match cost { // CR 118.12: This helper currently only handles the default @@ -7045,6 +7053,19 @@ pub fn handle_activate_ability( }); } + // CR 118.12a: Pre-check for OneOf costs — detour to WaitingFor before any cost payment. + if let Some(costs) = find_one_of_cost(cost) { + let mut pending_one_of = + PendingCast::new(source_id, CardId(0), resolved, ManaCost::NoCost); + pending_one_of.activation_cost = Some(cost.clone()); + pending_one_of.activation_ability_index = Some(ability_index); + return Ok(WaitingFor::ActivationCostOneOfChoice { + player, + costs: costs.clone(), + pending_cast: Box::new(pending_one_of), + }); + } + // CR 118.3: Pre-check for ReturnToHand costs — same WaitingFor detour pattern as // Sacrifice above. Ordering matters for Composite costs: Sacrifice wins if both are // present, but no real cards combine them. @@ -7307,7 +7328,8 @@ pub fn handle_cancel_cast( // Cost payment handlers are in casting_costs module. pub(crate) use super::casting_costs::{ - handle_discard_for_cost, handle_return_to_hand_for_cost, handle_sacrifice_for_cost, + handle_activation_cost_one_of_choice, handle_discard_for_cost, handle_return_to_hand_for_cost, + handle_sacrifice_for_cost, }; fn generic_mana_in_cost(cost: &AbilityCost) -> u32 { diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 755a00a71e..79f14d3c24 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -553,6 +553,46 @@ pub(crate) fn handle_discard_for_cost( } /// CR 118.3 + CR 601.2b: Complete sacrifice-as-cost after player selection. +pub(crate) fn handle_activation_cost_one_of_choice( + state: &mut GameState, + player: PlayerId, + mut pending: PendingCast, + costs: &[AbilityCost], + index: usize, + events: &mut Vec, +) -> Result { + if index >= costs.len() { + return Err(EngineError::InvalidAction(format!( + "Invalid OneOf cost branch index: {}", + index + ))); + } + + let chosen_cost = &costs[index]; + if !chosen_cost.is_payable(state, player, pending.object_id) { + return Err(EngineError::ActionNotAllowed( + "Chosen cost branch is not payable".to_string(), + )); + } + + // Replace the OneOf cost with the chosen branch in the pending cast + if let Some(AbilityCost::Composite { + costs: ref mut composite_costs, + }) = pending.activation_cost + { + for cost in composite_costs.iter_mut() { + if matches!(cost, AbilityCost::OneOf { .. }) { + *cost = chosen_cost.clone(); + break; + } + } + } else if matches!(pending.activation_cost, Some(AbilityCost::OneOf { .. })) { + pending.activation_cost = Some(chosen_cost.clone()); + } + + finish_pending_cost_or_cast(state, player, pending, events) +} + pub(crate) fn handle_sacrifice_for_cost( state: &mut GameState, player: PlayerId, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 7171c9e7f0..bc4461cbd1 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -1650,6 +1650,29 @@ fn apply_action( GameAction::CancelCast, ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), // CR 118.3: Player selected permanents to sacrifice as cost. + ( + WaitingFor::ActivationCostOneOfChoice { + player, + costs, + pending_cast, + }, + GameAction::ChooseActivationCostBranch { index }, + ) => engine_casting::handle_activation_cost_one_of_choice( + state, + *player, + *pending_cast.clone(), + costs, + index, + &mut events, + )?, + ( + WaitingFor::ActivationCostOneOfChoice { + player, + pending_cast, + .. + }, + GameAction::CancelCast, + ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events), ( WaitingFor::SacrificeForCost { player, diff --git a/crates/engine/src/game/engine_casting.rs b/crates/engine/src/game/engine_casting.rs index 9dfcd0cd49..c3bf3f08ec 100644 --- a/crates/engine/src/game/engine_casting.rs +++ b/crates/engine/src/game/engine_casting.rs @@ -1,4 +1,4 @@ -use crate::types::ability::{AdditionalCost, BeholdCostAction}; +use crate::types::ability::{AbilityCost, AdditionalCost, BeholdCostAction}; use crate::types::events::GameEvent; use crate::types::game_state::{ CollectEvidenceResume, GameState, PendingCast, PendingManaAbility, WaitingFor, @@ -90,6 +90,17 @@ pub(super) fn handle_discard_for_cost( ) } +pub(super) fn handle_activation_cost_one_of_choice( + state: &mut GameState, + player: PlayerId, + pending: PendingCast, + costs: &[AbilityCost], + index: usize, + events: &mut Vec, +) -> Result { + casting::handle_activation_cost_one_of_choice(state, player, pending, costs, index, events) +} + pub(super) fn handle_sacrifice_for_cost( state: &mut GameState, player: PlayerId, diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index aaef406276..f0ca880df5 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -1328,6 +1328,7 @@ impl GameRunner { WaitingFor::CommanderZoneChoice { .. } => "CommanderZoneChoice", WaitingFor::SeparatePilesPartition { .. } => "SeparatePilesPartition", WaitingFor::SeparatePilesChoice { .. } => "SeparatePilesChoice", + WaitingFor::ActivationCostOneOfChoice { .. } => "ActivationCostOneOfChoice", } } diff --git a/crates/engine/src/types/actions.rs b/crates/engine/src/types/actions.rs index 808c198729..5f3ebf02b3 100644 --- a/crates/engine/src/types/actions.rs +++ b/crates/engine/src/types/actions.rs @@ -401,6 +401,10 @@ pub enum GameAction { ChooseUnlessCostBranch { choice: UnlessCostBranch, }, + /// CR 118.12a: Choose which branch of a disjunctive activation cost to pay. + ChooseActivationCostBranch { + index: usize, + }, /// CR 508.1d + CR 508.1h + CR 509.1c + CR 509.1d: Pay or decline the aggregate /// combat tax (Ghostly Prison, Propaganda, Sphere of Safety, Windborn Muse). /// On accept the engine deducts the locked-in total and completes the paused @@ -1074,11 +1078,11 @@ impl GameAction { | GameAction::Concede { .. } | GameAction::Debug(_) | GameAction::GrantDebugPermission { .. } - | GameAction::RevokeDebugPermission { .. } => None, + | GameAction::RevokeDebugPermission { .. } + | GameAction::ChooseActivationCostBranch { .. } => None, } } } - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index bd8d1dc098..680c896374 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -2100,6 +2100,13 @@ pub enum WaitingFor { /// The pending cast to resume after the return is complete. pending_cast: Box, }, + /// CR 118.12a: Player must choose which branch of a disjunctive activation cost + /// (`AbilityCost::OneOf`) to pay. + ActivationCostOneOfChoice { + player: PlayerId, + costs: Vec, + pending_cast: Box, + }, /// Blight N — player must choose one creature to put N -1/-1 counters on as cost. BlightChoice { player: PlayerId, @@ -2751,6 +2758,7 @@ impl WaitingFor { | WaitingFor::DiscardForCost { player, .. } | WaitingFor::SacrificeForCost { player, .. } | WaitingFor::ReturnToHandForCost { player, .. } + | WaitingFor::ActivationCostOneOfChoice { player, .. } | WaitingFor::BlightChoice { player, .. } | WaitingFor::TapCreaturesForSpellCost { player, .. } | WaitingFor::BeholdForCost { player, .. } @@ -2860,6 +2868,7 @@ impl WaitingFor { | WaitingFor::DiscardForCost { pending_cast, .. } | WaitingFor::SacrificeForCost { pending_cast, .. } | WaitingFor::ReturnToHandForCost { pending_cast, .. } + | WaitingFor::ActivationCostOneOfChoice { pending_cast, .. } | WaitingFor::BlightChoice { pending_cast, .. } | WaitingFor::TapCreaturesForSpellCost { pending_cast, .. } | WaitingFor::BeholdForCost { pending_cast, .. }