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
117 changes: 84 additions & 33 deletions crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
use crate::types::ability::TapStateChange;
use crate::types::ability::{
AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AdditionalCost,
CardTypeSetSource, CastManaSpentMetric, CombatRelationSubject, ControllerRef,
CounterMoveSelection, DamageSource, Effect, EffectKind, EffectScope, FilterProp,
AnnouncedModalChoice, CardTypeSetSource, CastManaSpentMetric, CombatRelationSubject,
ControllerRef, CounterMoveSelection, DamageSource, Effect, EffectKind, EffectScope, FilterProp,
GameRestriction, ModalChoice, ModalSelectionCondition, ModalSelectionConstraint,
MultiTargetSpec, ObjectScope, PlayerFilter, PlayerScope, PtValue, QuantityExpr, QuantityRef,
ResolvedAbility, RestrictionPlayerScope, SpellContext, SubAbilityLink, TargetChoiceTiming,
Expand Down Expand Up @@ -994,6 +994,69 @@ pub fn modal_choice_with_target_assignment_limit(
Some(effective)
}

/// CR 603.3c + CR 700.2: the single authority for "can a legal set of modes be
/// chosen for this modal ability, from this source, right now?".
///
/// Runs the whole choice in the order the rules announce it: the dynamic
/// "choose up to X" cap ([`modal_choice_for_player`], CR 700.2 + CR 107.3m),
/// the modes unavailable for non-target reasons ([`compute_unavailable_modes`],
/// e.g. a NoRepeat constraint already spent), the per-mode target-legality
/// filter ([`filter_modes_by_target_legality`], CR 115.1), and finally the
/// cross-mode assignment cap ([`modal_choice_with_target_assignment_limit`]).
///
/// Returns the [`AnnouncedModalChoice`] the controller chooses against, or
/// `None` when no legal mode can be chosen — the case in which a triggered
/// ability is removed from the stack instead of resolving (CR 603.3c).
///
/// Every consumer of a triggered modal's legal-mode set goes through here:
/// the live dispatch announcement (`dispatch_pending_trigger_context`), the mode
/// prompt that surfaces that announcement
/// (`begin_pending_trigger_target_selection`), and the hypothetical payoff
/// preflight ([`execute_targets_satisfiable`]). No caller re-derives the choice,
/// so neither the prompt nor an AI eligibility query can disagree with what the
/// runtime announced.
pub fn resolve_legal_modal_choice(
state: &GameState,
source_id: ObjectId,
controller: PlayerId,
modal: &ModalChoice,
mode_abilities: &[AbilityDefinition],
) -> Option<AnnouncedModalChoice> {
let modal_for_player = modal_choice_for_player(
state,
controller,
source_id,
modal,
&crate::types::ability::SpellContext::default(),
);
let mut unavailable_modes = compute_unavailable_modes(state, source_id, &modal_for_player);
filter_modes_by_target_legality(
state,
source_id,
controller,
mode_abilities,
&modal_for_player,
&mut unavailable_modes,
);
let modal_for_player = modal_choice_with_target_assignment_limit(
state,
source_id,
controller,
&modal_for_player,
mode_abilities,
&unavailable_modes,
)?;
// CR 603.3c: an illegal mode can't be chosen; with every mode unavailable
// there is no choice to announce at all.
if unavailable_modes.len() >= modal_for_player.mode_count {
return None;
}
Some(AnnouncedModalChoice {
modal: modal_for_player,
unavailable_modes,
})
}

fn modal_indices_have_legal_target_assignment(
state: &GameState,
source_id: ObjectId,
Expand Down Expand Up @@ -1360,36 +1423,23 @@ pub fn execute_targets_satisfiable(
source: &crate::game::game_object::GameObject,
execute: &AbilityDefinition,
) -> bool {
// CR 603.3c: a MODAL execute carries a placeholder root and its targets in
// `mode_abilities` (which the root slot walk does not descend). Mirror the
// live trigger dispatch: filter each mode by its own target legality, then
// require a legal modal choice — a required "choose one/two …" whose modes
// are all target-unavailable is dropped (`DroppedNoLegalMode`), so it is not
// a live payoff.
// CR 603.3c: a MODAL execute carries a placeholder root and keeps its real
// effects — and therefore its target slots — in `mode_abilities`, which the
// root slot walk below does not descend. Ask the same modal-choice authority
// the live trigger dispatch asks: a required "choose one …" whose every mode
// is target-unavailable has no legal choice and is dropped, so it is not a
// live payoff.
if let Some(modal) = &execute.modal {
let mut unavailable_modes = Vec::new();
filter_modes_by_target_legality(
state,
source.id,
source.controller,
&execute.mode_abilities,
modal,
&mut unavailable_modes,
);
if unavailable_modes.len() >= modal.mode_count {
return false; // CR 603.3c: no legal mode
if !execute.mode_abilities.is_empty() {
return resolve_legal_modal_choice(
state,
source.id,
source.controller,
modal,
&execute.mode_abilities,
)
.is_some();
}
// CR 603.3d: the required choose-count must be satisfiable with legal
// target assignments across the surviving modes.
return modal_choice_with_target_assignment_limit(
state,
source.id,
source.controller,
modal,
&execute.mode_abilities,
&unavailable_modes,
)
.is_some();
}
// CR 603.3d: build the ability the same way the live trigger pipeline does
// (`build_resolved_from_def`) so a sub-ability chain's own target slots are
Expand Down Expand Up @@ -1427,9 +1477,10 @@ pub fn execute_targets_satisfiable(
pub fn ability_definition_supported(def: &AbilityDefinition) -> bool {
// CR 700.2: a modal ability carries a placeholder `Effect::Unimplemented`
// (`modal_placeholder`) root — its real effects live in `mode_abilities`, so
// the placeholder is NOT a gap. Only an `Unimplemented` root on a
// non-modal ability is a true unsupported node.
if matches!(*def.effect, Effect::Unimplemented { .. }) && def.modal.is_none() {
// the placeholder is NOT a gap. It is one again when there are no modes to
// stand in for it: then the placeholder itself is what would resolve.
let modal_placeholder = def.modal.is_some() && !def.mode_abilities.is_empty();
if matches!(*def.effect, Effect::Unimplemented { .. }) && !modal_placeholder {
return false;
}
if def
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/archenemy_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ fn deferred_scheme_trigger_blocks_abandon() {
may_trigger_origin: None,
subject_match_count: None,
die_result: None,
announced_modal_choice: None,
};
state.deferred_triggers.push(DeferredTrigger {
pending,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15230,6 +15230,7 @@ fn apply_mana_spell_grants(
may_trigger_origin: None,
subject_match_count: None,
die_result: None,
announced_modal_choice: None,
},
);
}
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/cipher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ fn recast_trigger(
may_trigger_origin: None,
subject_match_count: None,
die_result: None,
announced_modal_choice: None,
},
trigger_events: vec![event.clone()],
dispatch_origin: PendingTriggerDispatchOrigin::Normal,
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2188,6 +2188,7 @@ fn try_begin_reflexive_target_selection_inner(
// into the later fresh-`apply()` target-assign.
subject_match_count: freeze_reflexive_event_count(state, controller, source_id),
die_result: state.die_result_this_resolution,
announced_modal_choice: None,
};
let trigger_events =
crate::game::triggers::take_pending_trigger_event_batch(state, &pending);
Expand Down Expand Up @@ -2280,6 +2281,7 @@ fn try_begin_reflexive_target_selection_inner(
// creating ability so the reflexive entry can re-stamp it when it
// resolves as its own stack object.
die_result: state.die_result_this_resolution,
announced_modal_choice: None,
};
let trigger_events = crate::game::triggers::take_pending_trigger_event_batch(state, &pending);
let pending_for_state = pending.clone();
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/venture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ fn queue_room_trigger(
may_trigger_origin: None,
subject_match_count: None,
die_result: None,
announced_modal_choice: None,
};

// CR 603.2 + CR 309.4c: Dispatch through the standard
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/elimination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,7 @@ fn do_eliminate(
may_trigger_origin: None,
subject_match_count: None,
die_result: None,
announced_modal_choice: None,
},
events,
);
Expand Down
97 changes: 54 additions & 43 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use crate::types::zones::Zone;

use super::ability_utils::{
begin_target_selection_for_ability, build_target_slots, cap_distribution_target_slots,
compute_unavailable_modes, has_legal_target_assignment_for_ability, modal_choice_for_player,
has_legal_target_assignment_for_ability,
};
use super::casting;
use super::casting_costs;
Expand Down Expand Up @@ -7279,6 +7279,7 @@ fn apply_action(
may_trigger_origin: None,
subject_match_count: None,
die_result: None,
announced_modal_choice: None,
};
super::triggers::push_pending_trigger_to_stack(state, trigger, &mut events);

Expand Down Expand Up @@ -8641,49 +8642,59 @@ pub(super) fn begin_pending_trigger_target_selection(
};
let subject_match_count = trigger.subject_match_count;
let modal = modal.clone();
// CR 603.3c + CR 603.3d: a triggered modal's mode choice is announced as
// the ability is put on the stack, by the same process as casting a spell
// (CR 601.2c-d). The triggering event must be live for the ENTIRE choice,
// including the "choose up to X" dynamic cap resolved by
// modal_choice_for_player -- push the event window BEFORE cap resolution,
// not just around target-legality filtering, so event-context quantity
// refs (e.g. EventContextSourceModesChosen, Riku of Many Paths) resolve
// against the triggering spell rather than an unset event.
let context_snapshot = super::triggers::push_trigger_event_context(
state,
trigger_event.as_ref(),
&trigger_events,
subject_match_count,
);
let modal = modal_choice_for_player(
state,
player,
source_id,
&modal,
&crate::types::ability::SpellContext::default(),
);
let mut unavailable_modes = compute_unavailable_modes(state, source_id, &modal);
super::ability_utils::filter_modes_by_target_legality(
state,
source_id,
player,
&mode_abilities,
&modal,
&mut unavailable_modes,
);
super::triggers::restore_trigger_event_context(state, context_snapshot);
let Some(modal) = super::ability_utils::modal_choice_with_target_assignment_limit(
state,
source_id,
player,
&modal,
&mode_abilities,
&unavailable_modes,
) else {
super::stack::pop_uncommitted_pending_trigger_entry(state);
state.pending_trigger = None;
return Ok(None);
// CR 603.3c + CR 700.2b: this prompt SURFACES the mode choice that was
// announced when the ability was put on the stack — it does not make a
// second one. Both rules bind mode legality to that earlier moment, and
// the game state can move in between (an effect earlier in the same
// simultaneous cascade may remove a mode's only legal target, exactly
// as the target path below documents), so the announcement carried on
// the pending trigger is authoritative over anything re-derived here.
//
// Only a trigger parked without an announcement falls back — and it
// falls back to `resolve_legal_modal_choice`, the same single authority
// that produced the announcement and that the AI payoff preflight
// asks. This step must never re-implement the mode-choice sequence
// (dynamic cap → non-target unavailability → per-mode target legality →
// cross-mode assignment cap → CR 603.3c no-legal-mode verdict): a
// second copy is free to drift from the announcement, and it is the
// prompt that the controller is bound by.
let announced = match trigger.announced_modal_choice.clone() {
Some(announced) => *announced,
None => {
// CR 603.3d: the triggering event must be live for the ENTIRE
// choice, including the "choose up to X" dynamic cap -- push the
// event window BEFORE the authority runs, so event-context
// quantity refs (e.g. EventContextSourceModesChosen, Riku of
// Many Paths) resolve against the triggering spell rather than
// an unset event, exactly as the announcement path does.
let context_snapshot = super::triggers::push_trigger_event_context(
state,
trigger_event.as_ref(),
&trigger_events,
subject_match_count,
);
let resolved = super::ability_utils::resolve_legal_modal_choice(
state,
source_id,
player,
&modal,
&mode_abilities,
);
super::triggers::restore_trigger_event_context(state, context_snapshot);
let Some(resolved) = resolved else {
// CR 603.3c: no legal mode can be chosen — the ability is
// removed from the stack rather than resolving.
super::stack::pop_uncommitted_pending_trigger_entry(state);
state.pending_trigger = None;
return Ok(None);
};
resolved
}
};
let crate::types::ability::AnnouncedModalChoice {
modal,
unavailable_modes,
} = announced;

// CR 700.2b (override) + CR 701.9b (analogous): "choose ... at
// random" modal triggers (Cult of Skaro) are resolved inline by
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,7 @@ fn issue_3660_finalize_copy_retarget_stashes_offers_on_deferred_pause() {
may_trigger_origin: None,
subject_match_count: None,
die_result: None,
announced_modal_choice: None,
},
trigger_events: Vec::new(),
dispatch_origin: PendingTriggerDispatchOrigin::Normal,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/engine_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ fn pending_trigger_with_no_legal_target_at_choose_time_drops_not_errors() {
may_trigger_origin: None,
subject_match_count: None,
die_result: None,
announced_modal_choice: None,
};
let entry_id = ObjectId(state.next_object_id);
state.next_object_id += 1;
Expand Down
Loading
Loading