Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions crates/engine/src/ai_support/candidates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,16 @@ pub fn candidate_actions_broad(state: &GameState) -> Vec<CandidateAction> {
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,
Expand Down
24 changes: 23 additions & 1 deletion crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6454,6 +6454,14 @@ pub(crate) fn find_eligible_exile_for_cost_targets(
}
}

fn find_one_of_cost(cost: &AbilityCost) -> Option<&Vec<AbilityCost>> {
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameEvent>,
) -> Result<WaitingFor, EngineError> {
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,
Expand Down
23 changes: 23 additions & 0 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion crates/engine/src/game/engine_casting.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<GameEvent>,
) -> Result<WaitingFor, EngineError> {
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,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1328,6 +1328,7 @@ impl GameRunner {
WaitingFor::CommanderZoneChoice { .. } => "CommanderZoneChoice",
WaitingFor::SeparatePilesPartition { .. } => "SeparatePilesPartition",
WaitingFor::SeparatePilesChoice { .. } => "SeparatePilesChoice",
WaitingFor::ActivationCostOneOfChoice { .. } => "ActivationCostOneOfChoice",
}
}

Expand Down
8 changes: 6 additions & 2 deletions crates/engine/src/types/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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::*;
Expand Down
9 changes: 9 additions & 0 deletions crates/engine/src/types/game_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2100,6 +2100,13 @@ pub enum WaitingFor {
/// The pending cast to resume after the return is complete.
pending_cast: Box<PendingCast>,
},
/// CR 118.12a: Player must choose which branch of a disjunctive activation cost
/// (`AbilityCost::OneOf`) to pay.
ActivationCostOneOfChoice {
player: PlayerId,
costs: Vec<AbilityCost>,
pending_cast: Box<PendingCast>,
},
/// Blight N — player must choose one creature to put N -1/-1 counters on as cost.
BlightChoice {
player: PlayerId,
Expand Down Expand Up @@ -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, .. }
Expand Down Expand Up @@ -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, .. }
Expand Down