diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index ed77913be4..6e335cd477 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -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, @@ -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 { + 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, @@ -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 @@ -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 diff --git a/crates/engine/src/game/archenemy_tests.rs b/crates/engine/src/game/archenemy_tests.rs index 01a5b9eec3..f204967bd3 100644 --- a/crates/engine/src/game/archenemy_tests.rs +++ b/crates/engine/src/game/archenemy_tests.rs @@ -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, diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index bbedb54252..aa7eab984e 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -15230,6 +15230,7 @@ fn apply_mana_spell_grants( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }, ); } diff --git a/crates/engine/src/game/cipher.rs b/crates/engine/src/game/cipher.rs index b18344fc66..5adcb4f117 100644 --- a/crates/engine/src/game/cipher.rs +++ b/crates/engine/src/game/cipher.rs @@ -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, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 824bdcba1e..6a998f1bfd 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -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); @@ -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(); diff --git a/crates/engine/src/game/effects/venture.rs b/crates/engine/src/game/effects/venture.rs index fe7f6635ad..70cad0c363 100644 --- a/crates/engine/src/game/effects/venture.rs +++ b/crates/engine/src/game/effects/venture.rs @@ -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 diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 2989ce034c..20be050198 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -948,6 +948,7 @@ fn do_eliminate( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }, events, ); diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index a17810e751..7730ee530d 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -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; @@ -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); @@ -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 diff --git a/crates/engine/src/game/engine_keyword_action_stack_tests.rs b/crates/engine/src/game/engine_keyword_action_stack_tests.rs index c01d07b7ce..6dc5439949 100644 --- a/crates/engine/src/game/engine_keyword_action_stack_tests.rs +++ b/crates/engine/src/game/engine_keyword_action_stack_tests.rs @@ -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, diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index ac9f6b4e5c..c5f43fa040 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -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; diff --git a/crates/engine/src/game/engine_trigger_target_tests.rs b/crates/engine/src/game/engine_trigger_target_tests.rs index 09650f6ee4..5db0026ad5 100644 --- a/crates/engine/src/game/engine_trigger_target_tests.rs +++ b/crates/engine/src/game/engine_trigger_target_tests.rs @@ -106,6 +106,7 @@ fn trigger_target_selection_select_targets_pushes_to_stack() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -222,6 +223,7 @@ fn trigger_target_selection_rejects_illegal_target() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -312,6 +314,7 @@ fn triggered_modal_modes_with_targets_wait_for_target_selection() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -454,6 +457,7 @@ fn setup_vindictive_lich_pending_trigger(state: &mut GameState) { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -596,6 +600,7 @@ fn triggered_modal_modes_without_targets_consume_pending_trigger() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -721,6 +726,7 @@ fn triggered_commander_modal_cap_uses_controller_board_state() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -806,6 +812,7 @@ fn trigger_target_selection_enforces_different_player_constraint() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -963,6 +970,7 @@ fn choose_target_action_advances_trigger_selection_from_engine_state() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -1081,6 +1089,7 @@ fn triggered_modal_modes_reject_unsatisfiable_target_constraints() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let pending_for_state = pending.clone(); let mut setup_events = Vec::new(); @@ -1194,6 +1203,7 @@ fn all_modes_exhausted_clears_pending_trigger() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let pending_for_state = pending.clone(); let stack_before = state.stack.len(); diff --git a/crates/engine/src/game/planechase.rs b/crates/engine/src/game/planechase.rs index efe5841d38..6782d2acca 100644 --- a/crates/engine/src/game/planechase.rs +++ b/crates/engine/src/game/planechase.rs @@ -211,6 +211,7 @@ fn queue_planeswalk_trigger( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; crate::game::triggers::dispatch_synthetic_trigger(state, pending, events); } diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 4e42efddec..7b4043d695 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -4639,6 +4639,7 @@ mod tests { may_trigger_origin: Some(MayTriggerOrigin::Printed { trigger_index: 0 }), subject_match_count: None, die_result: None, + announced_modal_choice: None, })); state.waiting_for = WaitingFor::Priority { player: PlayerId(0), diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index cb2fa0cb32..25f19b409a 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -3,12 +3,12 @@ use std::collections::HashSet; use crate::database::synthesis::KeywordTriggerInstaller; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AdditionalCostOrigin, - BounceSelection, CardTypeSetSource, CastManaSpentMetric, ChosenAttribute, CommanderOwnership, - ControllerRef, CopyRetargetPermission, DelayedTriggerCondition, Effect, ModalChoice, - ObjectScope, OriginConstraint, PlayerFilter, PtValue, QuantityExpr, QuantityRef, RenownSubject, - ResolvedAbility, SacrificeCost, TargetFilter, TargetRef, TributeOutcome, TriggerCondition, - TriggerDefinition, TriggerDefinitionOccurrenceRef, TriggerDefinitionRef, TriggerEntry, - TriggerGrantProducerKey, TypeFilter, TypedFilter, + AnnouncedModalChoice, BounceSelection, CardTypeSetSource, CastManaSpentMetric, ChosenAttribute, + CommanderOwnership, ControllerRef, CopyRetargetPermission, DelayedTriggerCondition, Effect, + ModalChoice, ObjectScope, OriginConstraint, PlayerFilter, PtValue, QuantityExpr, QuantityRef, + RenownSubject, ResolvedAbility, SacrificeCost, TargetFilter, TargetRef, TributeOutcome, + TriggerCondition, TriggerDefinition, TriggerDefinitionOccurrenceRef, TriggerDefinitionRef, + TriggerEntry, TriggerGrantProducerKey, TypeFilter, TypedFilter, }; #[cfg(test)] use crate::types::ability::{EffectScope, TapStateChange}; @@ -86,6 +86,32 @@ pub struct PendingTrigger { pub modal: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub mode_abilities: Vec, + /// CR 603.3c + CR 700.2b: The mode choice `dispatch_pending_trigger_context` + /// announced as this ability was put on the stack — set only on the modal + /// dispatch path, and only after `resolve_legal_modal_choice` confirmed a + /// legal choice exists. + /// + /// Both rules fix mode legality *at the moment the ability is put on the + /// stack*: "if one of the modes would be illegal (due to an inability to + /// choose legal targets, for example), that mode can't be chosen". The + /// engine raises the controller's prompt in a later re-entry + /// (`begin_pending_trigger_target_selection`), 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 documents. + /// Re-deriving legality at prompt time would answer that later state, so the + /// announcement travels with the trigger instead. + /// + /// `None` for every non-modal trigger and for any trigger parked by a path + /// that never announced modes; the prompt then asks the same + /// `resolve_legal_modal_choice` authority itself rather than re-implementing + /// the choice. + /// + /// Boxed per the stack budget in [`crate::types::game_state_size`]: it embeds + /// a whole [`ModalChoice`] and is populated only on the modal dispatch path — + /// exactly the "large, rarely-populated field" that module says to box rather + /// than widen a ceiling for. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub announced_modal_choice: Option>, /// Human-readable trigger description from the Oracle text. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -1699,6 +1725,7 @@ fn collect_matching_triggers_inner( }, subject_match_count, die_result: None, + announced_modal_choice: None, }, trigger_events, batched: trig_def.batched, @@ -2686,6 +2713,7 @@ fn collect_latched_batched_zone_triggers( }), subject_match_count, die_result: None, + announced_modal_choice: None, }, trigger_events, batched: true, @@ -2958,6 +2986,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -3005,6 +3034,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -3048,6 +3078,7 @@ fn collect_pending_triggers_with_collection( }), subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -3093,6 +3124,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -3139,6 +3171,7 @@ fn collect_pending_triggers_with_collection( }), subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -3224,6 +3257,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -3641,6 +3675,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -3703,6 +3738,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } @@ -3763,6 +3799,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } @@ -3884,6 +3921,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } @@ -3955,6 +3993,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } @@ -4027,6 +4066,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -4085,6 +4125,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -4117,6 +4158,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -4152,6 +4194,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -4247,6 +4290,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -4291,6 +4335,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -4336,6 +4381,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); mark_speed_trigger_used(state, trigger_controller); } @@ -4383,6 +4429,7 @@ fn collect_pending_triggers_with_collection( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); } } @@ -4626,6 +4673,7 @@ fn ring_pending_trigger( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }) } @@ -6493,47 +6541,39 @@ fn dispatch_pending_trigger_context( &trigger_events, trigger.subject_match_count, ); - let modal_for_player = super::ability_utils::modal_choice_for_player( - state, - trigger.controller, - trigger.source_id, - modal_ref, - &crate::types::ability::SpellContext::default(), - ); - let mut unavailable_modes = super::ability_utils::compute_unavailable_modes( - state, - trigger.source_id, - &modal_for_player, - ); - super::ability_utils::filter_modes_by_target_legality( + // CR 603.3c: the whole mode choice — dynamic cap, modes unavailable + // for non-target reasons, per-mode target legality, and the + // cross-mode assignment cap — is settled by the shared + // `resolve_legal_modal_choice` authority, run inside the event window + // so every step sees the triggering event. The AI payoff preflight + // asks the same question of the same function, so its hypothetical + // answer cannot drift from this live one. + let legal_choice = super::ability_utils::resolve_legal_modal_choice( state, trigger.source_id, trigger.controller, + modal_ref, &trigger.mode_abilities, - &modal_for_player, - &mut unavailable_modes, ); restore_trigger_event_context(state, context_snapshot); - let Some(modal_for_player) = - super::ability_utils::modal_choice_with_target_assignment_limit( - state, - trigger.source_id, - trigger.controller, - &modal_for_player, - &trigger.mode_abilities, - &unavailable_modes, - ) - else { - return TriggerDispatchDisposition::DroppedNoLegalMode; - }; - if unavailable_modes.len() >= modal_for_player.mode_count { + let Some(announced) = legal_choice else { // CR 603.3c: No legal mode; drop the trigger entirely. return TriggerDispatchDisposition::DroppedNoLegalMode; - } + }; + let AnnouncedModalChoice { + modal: modal_for_player, + unavailable_modes, + } = announced.clone(); let mode_abilities = trigger.mode_abilities.clone(); let controller = trigger.controller; let source_id = trigger.source_id; - let pending_for_state = trigger.clone(); + // CR 603.3c + CR 700.2b: the announcement is complete HERE — this is + // the moment the ability is put on the stack, and it is the moment + // both rules bind mode legality to. Carry it on the parked trigger so + // the controller's prompt surfaces this answer instead of a fresh + // derivation against whatever state exists when the prompt is raised. + let mut pending_for_state = trigger.clone(); + pending_for_state.announced_modal_choice = Some(Box::new(announced)); let entry_id = push_pending_trigger_to_stack_with_event_batch( state, trigger, @@ -7273,6 +7313,7 @@ pub fn check_state_triggers(state: &mut GameState) { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }); } } @@ -7391,6 +7432,7 @@ fn delayed_trigger_to_context( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }) } @@ -11374,6 +11416,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); state.waiting_for = WaitingFor::OptionalEffectChoice { player: controller, @@ -16038,6 +16081,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let mut events = Vec::new(); @@ -16117,6 +16161,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let draw_trig = parse_trigger_line(DRAW_ETB, "Curiosity Crafter"); @@ -16140,6 +16185,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; dispatch_collected_triggers( @@ -16593,10 +16639,7 @@ pub mod tests { controller, condition: None, ability: Box::new(ResolvedAbility::new( - Effect::Unimplemented { - name: "modal_placeholder".to_string(), - description: None, - }, + Effect::unimplemented("modal_placeholder", "choose one —"), vec![], source, controller, @@ -16620,6 +16663,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let context = PendingTriggerContext { pending, @@ -16674,6 +16718,93 @@ pub mod tests { ); } + /// CR 603.3c + CR 700.2b: mode legality is fixed WHEN THE ABILITY IS PUT ON + /// THE STACK — "if one of the modes would be illegal (due to an inability to + /// choose legal targets, for example), that mode can't be chosen". The + /// controller's prompt is raised in a later re-entry + /// (`begin_pending_trigger_target_selection`), and the board can move in + /// between: the target path in that same function documents the case where + /// "an effect earlier in the SAME simultaneous cascade removed the only legal + /// target". This test makes that window explicit — the sole legal target for + /// both modes leaves the battlefield after the announcement — and pins that + /// the prompt still offers the ANNOUNCED choice. + /// + /// Discriminating, and verified so by experiment: with the carried + /// announcement bypassed, this test fails with `got None` — re-deriving mode + /// legality at prompt time finds no legal target for either mode, so it drops + /// the trigger and never raises a prompt at all. That is the defect, not just + /// a duplicated derivation: the controller loses a mode choice CR 700.2b + /// already granted them. The ability may still be removed afterwards, but by + /// CR 603.3d once a chosen mode has no legal target — the rule that actually + /// governs that removal. + #[test] + fn mode_prompt_surfaces_the_announcement_not_a_later_re_derivation() { + let mut state = GameState::new_two_player(42); + let controller = PlayerId(0); + let source = create_object( + &mut state, + CardId(0x0603_3C03), + controller, + "Modal Engine".to_string(), + Zone::Battlefield, + ); + let creature = make_creature(&mut state, PlayerId(1), "Bear", 2, 2); + + // Announcement: the Bear is a legal target, so both modes are choosable. + let disposition = dispatch_two_mode_creature_target_modal(&mut state, source, controller); + assert!( + matches!(disposition, TriggerDispatchDisposition::Paused), + "a choosable modal trigger must park for its mode prompt, got {disposition:?}" + ); + let announced = state + .pending_trigger + .as_ref() + .and_then(|pending| pending.announced_modal_choice.clone()) + .expect("dispatch must carry the announced mode choice on the parked trigger"); + assert!( + announced.unavailable_modes.is_empty(), + "with the Bear present both modes were announced as choosable, got {:?}", + announced.unavailable_modes + ); + + // The only legal target leaves the battlefield BEFORE the prompt is + // raised — the simultaneous-cascade window the target path documents. + // Uses the real zone primitive: `create_object` registers battlefield + // membership in a separate zone list, so mutating `GameObject::zone` + // alone would leave the creature targetable and make this test vacuous. + let mut zone_events = Vec::new(); + crate::game::zones::move_to_zone(&mut state, creature, Zone::Graveyard, &mut zone_events); + assert!( + !crate::game::targeting::zone_object_ids(&state, Zone::Battlefield).contains(&creature), + "the injected drift must actually remove the Bear from the battlefield, \ + otherwise this test cannot tell a re-derivation from the announcement" + ); + + let waiting = crate::game::engine::begin_pending_trigger_target_selection(&mut state) + .expect("raising the mode prompt must not error"); + match waiting { + Some(crate::types::game_state::WaitingFor::AbilityModeChoice { + modal, + unavailable_modes, + .. + }) => { + assert_eq!( + unavailable_modes, announced.unavailable_modes, + "the prompt must offer the announced unavailable set (CR 700.2b), \ + not one re-derived after the target left" + ); + assert_eq!( + modal.max_choices, announced.modal.max_choices, + "the prompt must offer the announced cap" + ); + } + other => panic!( + "the announced mode choice must still be prompted (CR 603.3c); a \ + re-derivation would have dropped the trigger instead, got {other:?}" + ), + } + } + #[test] fn keeper_of_the_accord_creature_intervening_if_false_when_tied() { let def = crate::parser::oracle_trigger::parse_trigger_line( @@ -27024,6 +27155,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }) } @@ -27141,6 +27273,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }) } @@ -27359,6 +27492,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }) } @@ -29687,6 +29821,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }, &mut Vec::new(), ); @@ -29935,6 +30070,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }, &mut Vec::new(), ); @@ -30037,6 +30173,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }, &mut Vec::new(), ); @@ -30229,6 +30366,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }, &mut Vec::new(), ); @@ -30319,6 +30457,7 @@ pub mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }, &mut Vec::new(), ); diff --git a/crates/engine/src/game/triggers_dedup_regression_tests.rs b/crates/engine/src/game/triggers_dedup_regression_tests.rs index 8a94a46688..e37e0cf9e3 100644 --- a/crates/engine/src/game/triggers_dedup_regression_tests.rs +++ b/crates/engine/src/game/triggers_dedup_regression_tests.rs @@ -3326,6 +3326,7 @@ fn order_triggers_distinct_event_context_still_prompt() { may_trigger_origin: None, subject_match_count: Some(count), die_result: None, + announced_modal_choice: None, }) }; let ctx_a = make_ctx(ObjectId(1), 1); @@ -3385,6 +3386,7 @@ fn order_triggers_event_context_ability_still_prompts_on_distinct_events() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }) }; let ctx_a = make_ctx(ObjectId(1), ObjectId(11)); @@ -3436,6 +3438,7 @@ fn archenemy_hero_team_orders_triggers_from_multiple_heroes_together() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }) }; diff --git a/crates/engine/src/game/triggers_ordering_parity_tests.rs b/crates/engine/src/game/triggers_ordering_parity_tests.rs index c0c8b70456..0389a6758c 100644 --- a/crates/engine/src/game/triggers_ordering_parity_tests.rs +++ b/crates/engine/src/game/triggers_ordering_parity_tests.rs @@ -948,6 +948,7 @@ fn ctx( may_trigger_origin: None, subject_match_count: None, die_result, + announced_modal_choice: None, }) } @@ -1410,6 +1411,7 @@ fn ctx_c( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }) } diff --git a/crates/engine/src/game/triggers_pr7_order_template_tests.rs b/crates/engine/src/game/triggers_pr7_order_template_tests.rs index 769c766fb4..013bf19986 100644 --- a/crates/engine/src/game/triggers_pr7_order_template_tests.rs +++ b/crates/engine/src/game/triggers_pr7_order_template_tests.rs @@ -53,6 +53,7 @@ fn mk_ctx( may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }, trigger_events: Vec::new(), dispatch_origin: PendingTriggerDispatchOrigin::Normal, diff --git a/crates/engine/src/game/triggers_push_first_contract_tests.rs b/crates/engine/src/game/triggers_push_first_contract_tests.rs index 60f94276d3..65f8d015c0 100644 --- a/crates/engine/src/game/triggers_push_first_contract_tests.rs +++ b/crates/engine/src/game/triggers_push_first_contract_tests.rs @@ -420,6 +420,7 @@ fn push_first_no_legal_modes_modal_trigger_dropped_silently() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let stack_before = state.stack.len(); @@ -530,6 +531,7 @@ fn random_modal_trigger_resolves_without_prompting() { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let stack_before = state.stack.len(); diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 3a8322bce2..47653538d3 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -16900,6 +16900,29 @@ pub struct ModalChoice { pub dynamic_max_choices: Option, } +/// CR 603.3c + CR 700.2b: The mode choice **announced** for a modal ability as it +/// is put on the stack — the effective [`ModalChoice`] (dynamic cap already +/// resolved) paired with the mode indices that cannot be chosen. +/// +/// CR 603.3c and CR 700.2b both bind a modal triggered ability's mode legality to +/// the moment the ability is put on the stack: "if one of the modes would be +/// illegal (due to an inability to choose legal targets, for example), that mode +/// can't be chosen". This engine necessarily splits that one announcement across +/// a pause — the entry is pushed, then the controller is prompted — so the answer +/// is bound into a single value, produced once by +/// `ability_utils::resolve_legal_modal_choice` and carried on +/// `PendingTrigger::announced_modal_choice`, rather than left as a loose +/// `(ModalChoice, Vec)` pair that each half re-derives for itself. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AnnouncedModalChoice { + /// The effective modal header the controller chooses against. + pub modal: ModalChoice, + /// Mode indices that cannot be chosen — CR 700.2d repeat constraints and + /// CR 115.1 target-legality failures. Ascending and deduplicated. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unavailable_modes: Vec, +} + /// Selection constraints attached to a modal choice header. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 75ee62bbed..16b81a0aae 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -22336,6 +22336,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; let json = serde_json::to_string(&trigger).unwrap(); let deserialized: PendingTrigger = serde_json::from_str(&json).unwrap(); @@ -22402,6 +22403,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); let json = serde_json::to_string(&state).unwrap(); diff --git a/crates/engine/tests/integration/breeches_blastmaker_coin_flip_copy.rs b/crates/engine/tests/integration/breeches_blastmaker_coin_flip_copy.rs index f305cf3e19..444375b546 100644 --- a/crates/engine/tests/integration/breeches_blastmaker_coin_flip_copy.rs +++ b/crates/engine/tests/integration/breeches_blastmaker_coin_flip_copy.rs @@ -332,6 +332,7 @@ fn setup_breeches_runtime(seed: u64) -> (GameState, ObjectId, ObjectId) { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }, &mut events, ); diff --git a/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs b/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs index 5d462b98f3..772db4710b 100644 --- a/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs +++ b/crates/engine/tests/integration/cr733_resolved_trigger_collection.rs @@ -58,6 +58,7 @@ fn pending_context(source_id: ObjectId) -> PendingTriggerContext { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }, trigger_events: Vec::new(), dispatch_origin: PendingTriggerDispatchOrigin::Normal, diff --git a/crates/engine/tests/integration/game_state_boxed_ability_serde.rs b/crates/engine/tests/integration/game_state_boxed_ability_serde.rs index c98849ec9f..b52195682a 100644 --- a/crates/engine/tests/integration/game_state_boxed_ability_serde.rs +++ b/crates/engine/tests/integration/game_state_boxed_ability_serde.rs @@ -120,6 +120,7 @@ fn populated_state() -> GameState { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); // Populated so the `#[serde(skip)]` assertion in // `boxing_introduces_no_wrapper_level_in_the_wire_shape` discriminates. With diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 2f4ef5c805..8ed56735d0 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -885,6 +885,7 @@ mod total_war_attacking_player_scope; mod tracked_set_anaphor_quantity_binds; mod treasured_find_regression; mod trespassers_curse_enchanted_player_trigger; +mod trigger_modal_choice_single_authority; mod true_conviction_double_keyword_grant; mod turn_based_draw_step_miracle_offer; mod turn_control_priority_softlock; diff --git a/crates/engine/tests/integration/trigger_modal_choice_single_authority.rs b/crates/engine/tests/integration/trigger_modal_choice_single_authority.rs new file mode 100644 index 0000000000..59b38c9c8d --- /dev/null +++ b/crates/engine/tests/integration/trigger_modal_choice_single_authority.rs @@ -0,0 +1,263 @@ +//! CR 603.3c + CR 603.3d: a modal triggered ability's mode choice is announced +//! when the ability is put on the stack. This engine necessarily splits that one +//! announcement across a pause — `dispatch_pending_trigger_context` resolves the +//! legal choice and pushes the entry, then `begin_pending_trigger_target_selection` +//! raises the `AbilityModeChoice` prompt — and the two halves live in different +//! modules. +//! +//! These tests pin the contract that makes that split safe: the prompt the +//! controller actually sees is the answer of the ONE authority, +//! `ability_utils::resolve_legal_modal_choice`, evaluated in the triggering-event +//! window. Neither half may 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), because a second copy is free to drift +//! from the announcement and it is the *prompt* the player is bound by. +//! +//! Both halves of the announcement are covered non-vacuously by real cards driven +//! through the production cast pipeline: +//! * the CAP, via Riku's `EventContextSourceModesChosen` "choose up to X" +//! (`max_choices` is event-context-dependent, so it also proves the prompt is +//! built inside the trigger-event window); +//! * the UNAVAILABLE-MODE SET, via Bumi's Earthbend mode, which requires a +//! target land (CR 115.1) that does not exist on an empty board. +//! +//! Oracle text is the same engine-authoritative text used by +//! `riku_modal_modes_chosen_cap.rs`. + +use engine::game::ability_utils::resolve_legal_modal_choice; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::{AnnouncedModalChoice, TargetRef}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; + +const RIKU_ORACLE: &str = "Whenever you cast a modal spell, choose up to X, where X is the number of times you chose a mode for that spell —\n\u{2022} Exile the top card of your library. Until the end of your next turn, you may play it.\n\u{2022} Put a +1/+1 counter on Riku. It gains trample until end of turn.\n\u{2022} Create a 1/1 blue Bird creature token with flying."; + +const ABRADE_ORACLE: &str = + "Choose one \u{2014}\n\u{2022} Abrade deals 3 damage to target creature.\n\u{2022} Destroy target artifact."; + +const BUMI_ORACLE: &str = "When Bumi enters, choose up to X, where X is the number of Lesson cards in your graveyard \u{2014}\n\u{2022} Put three +1/+1 counters on Bumi.\n\u{2022} Target player scries 3.\n\u{2022} Earthbend 3."; + +/// What the paused `AbilityModeChoice` prompt offers the controller. +struct PromptedChoice { + max_choices: usize, + mode_count: usize, + unavailable_modes: Vec, +} + +/// Ask the single authority the same question the engine asked when it announced +/// the choice, using the paused `pending_trigger` as the input the announcement +/// used: the RAW modal header off the card, that trigger's own source/controller, +/// and its triggering event restored as the live event window (what +/// `push_trigger_event_context` does around both halves). +/// +/// Panics if no modal pending trigger is parked — that would mean the prompt was +/// reached without the in-flight trigger the contract is about. +fn authority_answer(runner: &mut GameRunner) -> AnnouncedModalChoice { + let pending = runner + .state() + .pending_trigger + .as_ref() + .expect("a modal pending trigger must still be parked while its mode prompt is outstanding") + .clone(); + let modal = pending + .modal + .as_ref() + .expect("the parked trigger must carry its raw modal header"); + + // Restore the trigger-event window the announcement ran inside. The engine + // restores it after each half, so the paused snapshot has no live event. + let state = runner.state_mut(); + state.current_trigger_event = pending.trigger_event.clone(); + state.current_trigger_events = pending.trigger_event.iter().cloned().collect(); + state.current_trigger_match_count = pending.subject_match_count; + + let answer = resolve_legal_modal_choice( + runner.state(), + pending.source_id, + pending.controller, + modal, + &pending.mode_abilities, + ) + .expect("the authority must report a legal choice for a trigger that reached its mode prompt"); + + let state = runner.state_mut(); + state.current_trigger_event = None; + state.current_trigger_events = Vec::new(); + state.current_trigger_match_count = None; + answer +} + +/// Drive the pipeline until a triggered `AbilityModeChoice` is outstanding, +/// answering the cast's own mode/target windows on the way, and return what the +/// prompt offers. Panics if the run settles at `Priority` (the trigger never +/// fired) so a vacuous pass is impossible. +fn drive_to_mode_prompt( + runner: &mut GameRunner, + modes: &[usize], + targets: &[ObjectId], +) -> PromptedChoice { + let mut remaining_targets = targets.to_vec(); + for _ in 0..128 { + match runner.state().waiting_for.clone() { + WaitingFor::AbilityModeChoice { + modal, + unavailable_modes, + .. + } => { + return PromptedChoice { + max_choices: modal.max_choices, + mode_count: modal.mode_count, + unavailable_modes, + } + } + WaitingFor::ModeChoice { .. } => { + runner + .act(GameAction::SelectModes { + indices: modes.to_vec(), + }) + .expect("SelectModes must be accepted"); + } + WaitingFor::TargetSelection { .. } => { + let target = remaining_targets.remove(0); + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }) + .expect("ChooseTarget must be accepted"); + } + WaitingFor::OrderTriggers { .. } => { + engine::game::triggers::drain_order_triggers_with_identity(runner.state_mut()); + } + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("passing priority must be accepted"); + } + other => panic!( + "unexpected WaitingFor while driving to the mode prompt: {}", + other.variant_name() + ), + } + } + panic!("pipeline did not reach a triggered AbilityModeChoice within the step budget"); +} + +/// CAP HALF — CR 603.3d + CR 700.2b: Riku's triggered modal offers a "choose up +/// to X" cap that reads the triggering spell's chosen-mode count +/// (`EventContextSourceModesChosen`). The paused prompt's `max_choices` must be +/// exactly what `resolve_legal_modal_choice` reports, and must be the live +/// event-context value (1 for a one-mode Abrade) rather than 0 or Riku's own +/// `mode_count` — so this also proves the prompt is built with the +/// triggering-event window pushed, as the announcement was. +#[test] +fn prompt_cap_equals_modal_choice_authority() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_creature_from_oracle(P0, "Riku, of Many Paths", 2, 4, RIKU_ORACLE); + let dummy = scenario.add_creature(P1, "Target Dummy", 3, 3).id(); + let abrade = scenario + .add_spell_to_hand_from_oracle(P0, "Abrade", true, ABRADE_ORACLE) + .with_mana_cost(ManaCost::generic(0)) + .id(); + let mut runner = scenario.build(); + + let card_id = runner.state().objects[&abrade].card_id; + runner + .act(GameAction::CastSpell { + object_id: abrade, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("CastSpell must be accepted"); + + let prompt = drive_to_mode_prompt(&mut runner, &[0], &[dummy]); + + // Non-vacuous: the event-context cap really resolved off the cast spell. + assert_eq!( + prompt.max_choices, 1, + "Riku's cap must be the 1 mode chosen for Abrade (CR 700.2d), proving the \ + prompt resolved the dynamic cap inside the trigger-event window" + ); + assert_eq!(prompt.mode_count, 3, "Riku's header must parse three modes"); + + let announced = authority_answer(&mut runner); + assert_eq!( + prompt.max_choices, announced.modal.max_choices, + "the prompt's cap must BE the single authority's answer, not an \ + independently derived one (CR 603.3c)" + ); + assert_eq!( + prompt.unavailable_modes, announced.unavailable_modes, + "the prompt's unavailable-mode set must BE the single authority's answer" + ); +} + +/// UNAVAILABLE HALF — CR 603.3c + CR 115.1: Bumi's ETB modal includes +/// "Earthbend 3", which requires a target land. With no land on the battlefield +/// that mode cannot be chosen, so the announcement marks it unavailable. The +/// paused prompt's unavailable set must be exactly the authority's — and must be +/// non-empty here, so the equality is not satisfied by two empty vectors. +#[test] +fn prompt_unavailable_modes_equal_modal_choice_authority() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // Two Lessons in the graveyard so the dynamic cap resolves to 2 (< mode_count), + // keeping the cap live rather than saturated at the clamp. + for i in 0..2 { + scenario + .add_spell_to_graveyard(P0, &format!("Lesson {i}"), false) + .with_subtypes(vec!["Lesson"]); + } + let bumi = scenario + .add_creature_to_hand_from_oracle(P0, "Bumi, King of Three Trials", 4, 4, BUMI_ORACLE) + .with_mana_cost(ManaCost::generic(0)) + .id(); + let mut runner = scenario.build(); + + // Reach-guard: the Earthbend mode is unavailable because NO land exists. + assert!( + !runner.state().objects.values().any(|obj| obj.zone + == engine::types::zones::Zone::Battlefield + && obj + .card_types + .core_types + .contains(&engine::types::card_type::CoreType::Land)), + "the fixture must start with no land on the battlefield for Earthbend to be \ + target-unavailable" + ); + + let card_id = runner.state().objects[&bumi].card_id; + runner + .act(GameAction::CastSpell { + object_id: bumi, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting Bumi must be accepted"); + + let prompt = drive_to_mode_prompt(&mut runner, &[], &[]); + + // Non-vacuous: at least one mode really is unavailable. + assert!( + !prompt.unavailable_modes.is_empty(), + "Earthbend needs a target land (CR 115.1); with no land its mode must be \ + announced unavailable, got {:?}", + prompt.unavailable_modes + ); + + let announced = authority_answer(&mut runner); + assert_eq!( + prompt.unavailable_modes, announced.unavailable_modes, + "the prompt's unavailable-mode set must BE the single authority's answer, not \ + an independently derived one (CR 603.3c)" + ); + assert_eq!( + prompt.max_choices, announced.modal.max_choices, + "the prompt's cap must BE the single authority's answer" + ); +} diff --git a/crates/phase-ai/src/config.rs b/crates/phase-ai/src/config.rs index e391be8182..6537db8c24 100644 --- a/crates/phase-ai/src/config.rs +++ b/crates/phase-ai/src/config.rs @@ -503,6 +503,10 @@ pub struct PolicyPenalties { /// draw" engine (preference band, per engine). #[serde(default = "default_draw_payoff_bonus")] pub draw_payoff_bonus: f64, + /// CR 702.29c/d: card-equivalent value of cycling into one active + /// "whenever you cycle" engine (preference band, per engine). + #[serde(default = "default_cycling_payoff_bonus")] + pub cycling_payoff_bonus: f64, } impl Default for PolicyPenalties { @@ -577,6 +581,7 @@ impl Default for PolicyPenalties { devotion_pip_progress: default_devotion_pip_progress(), devotion_god_activation: default_devotion_god_activation(), draw_payoff_bonus: default_draw_payoff_bonus(), + cycling_payoff_bonus: default_cycling_payoff_bonus(), } } } @@ -674,6 +679,9 @@ fn default_devotion_god_activation() -> f64 { fn default_draw_payoff_bonus() -> f64 { 0.6 } +fn default_cycling_payoff_bonus() -> f64 { + 0.6 +} fn default_sacrifice_token_cost() -> f64 { 0.5 } @@ -822,6 +830,10 @@ pub const UNTUNED_POLICY_PENALTY_FIELDS: &[(&str, &str)] = &[ "draw_payoff_bonus", "CR 121.1 per-engine draw-payoff weight — awaiting a paired-seed ai-gate calibration.", ), + ( + "cycling_payoff_bonus", + "CR 702.29c/d per-engine cycling payoff weight — awaiting a paired-seed ai-gate calibration.", + ), ( "poison_clock_pressure", "CR 104.3d win-detector weight — a critical-band term whose magnitude is \ diff --git a/crates/phase-ai/src/features/cycling.rs b/crates/phase-ai/src/features/cycling.rs new file mode 100644 index 0000000000..d384edca73 --- /dev/null +++ b/crates/phase-ai/src/features/cycling.rs @@ -0,0 +1,158 @@ +//! Cycling feature — structural detection of a "cycling matters" payoff deck. +//! +//! Parser AST verification — VERIFIED against engine source: +//! - `Keyword::Cycling(CyclingCost)` at `crates/engine/src/types/keywords.rs:694` +//! and `Keyword::Typecycling { .. }` at `keywords.rs:966` (CR 702.29a/e) — the +//! cyclable cards (the enablers). +//! - `TriggerMode::Cycled` at `crates/engine/src/types/triggers.rs:397` +//! (CR 702.29c: "when you cycle this card") and `TriggerMode::CycledOrDiscarded` +//! at `triggers.rs:400` (CR 702.29d: "whenever you cycle or discard a card") — +//! the payoffs. +//! - `TriggerDefinition.valid_card` / `.valid_target` (`Option`) at +//! `ability.rs:4522`/`:4539` — used to keep only controller-scoped, non-self +//! ("whenever you cycle A card") engine payoffs. +//! +//! No parser remediation required — every axis is expressible over existing +//! typed AST. +//! +//! ## Why this axis exists +//! +//! Cycling is card-neutral selection, so the AI's generic priors treat it as +//! marginal — and `CyclingDisciplinePolicy` only adds *patience* (it penalises +//! cycling away a needed land), while `self_cost_value` explicitly defers +//! cycling value (`self_cost_cycling_deferred`). Nothing models the *upside*: in +//! a deck with a "whenever you cycle a card" engine (Astral Drift, Drannith +//! Stinger, New Perspectives), every cycle is a repeatable value trigger, and +//! the AI should cycle eagerly. This axis lets a policy see that engine. +//! +//! ## Boundary with `spellslinger_prowess` / `mill` +//! +//! Spellslinger counts *spell-cast* triggers; cycling triggers on the cycling +//! keyword action (CR 702.29c), a disjoint event. A cycling card that is also an +//! instant/sorcery can read on both axes — that overlap is intentional and the +//! axes stay independent. + +use engine::game::ability_utils::ability_definition_supported; +use engine::game::DeckEntry; +use engine::types::ability::{TargetFilter, TriggerDefinition}; +use engine::types::card_type::CoreType; +use engine::types::keywords::Keyword; +use engine::types::triggers::TriggerMode; + +use crate::features::commitment; + +/// Commitment at or above which "cycling matters" is a real plan for this deck +/// rather than incidental card smoothing. Gates `CyclingPayoffPolicy::activation`. +pub const CYCLING_PAYOFF_FLOOR: f32 = 0.35; + +/// CR 702.29: per-deck cycling-payoff classification. +/// +/// Populated once per game from `DeckEntry` data. Detection is structural over +/// `CardFace.keywords` and `CardFace.triggers` — never by card name. +#[derive(Debug, Clone, Default)] +pub struct CyclingFeature { + /// Cyclable cards — CR 702.29a Cycling / CR 702.29e Typecycling. The + /// enablers that feed the payoff engine. + pub source_count: u32, + /// Permanents carrying a "whenever you cycle a card" engine trigger + /// (CR 702.29c/d), controller-scoped and not self-referential — the payoffs + /// that make cycling actively good. + pub payoff_count: u32, + /// `0.0..=1.0` — how central cycling-as-a-payoff is to this deck. Consumed by + /// `CyclingPayoffPolicy::activation` as the single scaling knob. + pub commitment: f32, +} + +/// Structural detection over each `DeckEntry`'s `CardFace` AST. +pub fn detect(deck: &[DeckEntry]) -> CyclingFeature { + if deck.is_empty() { + return CyclingFeature::default(); + } + + let mut source_count = 0u32; + let mut payoff_count = 0u32; + let mut total_nonland = 0u32; + + for entry in deck { + let face = &entry.card; + if !face.card_type.core_types.contains(&CoreType::Land) { + total_nonland = total_nonland.saturating_add(entry.count); + } + + if is_cycle_source_parts(&face.keywords) { + source_count = source_count.saturating_add(entry.count); + } + if is_cycle_payoff_parts(&face.triggers) { + payoff_count = payoff_count.saturating_add(entry.count); + } + } + + let commitment = compute_commitment(source_count, payoff_count, total_nonland); + + CyclingFeature { + source_count, + payoff_count, + commitment, + } +} + +/// CR 702.29a/e: the face is cyclable — it carries Cycling or Typecycling. +pub(crate) fn is_cycle_source_parts(keywords: &[Keyword]) -> bool { + keywords + .iter() + .any(|k| matches!(k, Keyword::Cycling(_) | Keyword::Typecycling { .. })) +} + +/// CR 702.29c/d: the triggers carry a "whenever you cycle a card" engine — +/// a repeatable payoff, not a one-shot self-cycle bonus. Parts-based so it +/// classifies both a deck-time `CardFace.triggers` slice and a live +/// `GameObject.trigger_definitions` iterator (the runtime trigger authority). +pub(crate) fn is_cycle_payoff_parts<'a>( + triggers: impl IntoIterator, +) -> bool { + triggers.into_iter().any(is_cycle_payoff_trigger) +} + +/// Single-trigger structural classifier (mode + scope), exposed so the policy +/// can pair it with live per-turn firing eligibility per trigger entry. +pub(crate) fn is_cycle_payoff_trigger(t: &TriggerDefinition) -> bool { + // 1. Mode fires on a cycle event (CR 702.29c/d). + if !matches!(t.mode, TriggerMode::Cycled | TriggerMode::CycledOrDiscarded) { + return false; + } + // 2. Caster-scoped only: an opponent-scoped "whenever an opponent cycles" + // punisher is not your payoff. + if !matches!(&t.valid_target, None | Some(TargetFilter::Controller)) { + return false; + } + // 3. Exclude the pure self-cycle bonus ("when you cycle THIS card"): that is + // a cyclable card with upside (already counted as a source), not a + // battlefield engine that rewards cycling other cards. + if matches!(&t.valid_card, Some(TargetFilter::SelfRef)) { + return false; + } + // 4. The payoff must resolve to a real effect. A missing execute or an + // unsupported one (`TriggerNoExecute` / `Effect::Unimplemented`) produces + // no value, so it is not an engine — the same shared support authority the + // live fireability preflight consults. + t.execute + .as_deref() + .is_some_and(ability_definition_supported) +} + +/// Calibration: a dedicated cycling-payoff deck (e.g. Pioneer/Historic cycling: +/// ~18 cyclers + ~6 engines like Astral Drift / Drannith Stinger over ~36 +/// nonland) → commitment ≈ 0.85. Anti-calibration: a control deck that plays two +/// cycling lands and no engine → below `CYCLING_PAYOFF_FLOOR`; a deck with an +/// engine but no cyclers, or cyclers but no engine → 0.0. +/// +/// Geometric mean over (source, payoff): BOTH pillars are mandatory. Cyclers +/// with no engine is just card smoothing (`CyclingDisciplinePolicy` governs it); +/// an engine with no cyclers never triggers. +fn compute_commitment(source_count: u32, payoff_count: u32, total_nonland: u32) -> f32 { + // ~18 cyclers per 60 nonland is a fully-committed cycling shell. + let source_density = (commitment::density_per_60(source_count, total_nonland) / 18.0).min(1.0); + // ~6 engine payoffs per 60 nonland is a fully-committed payoff base. + let payoff_density = (commitment::density_per_60(payoff_count, total_nonland) / 6.0).min(1.0); + commitment::geometric_mean(&[source_density, payoff_density]) +} diff --git a/crates/phase-ai/src/features/mod.rs b/crates/phase-ai/src/features/mod.rs index 98dc265fd2..ef36d86251 100644 --- a/crates/phase-ai/src/features/mod.rs +++ b/crates/phase-ai/src/features/mod.rs @@ -12,6 +12,7 @@ pub mod artifacts; pub mod blink; pub mod commitment; pub mod control; +pub mod cycling; pub mod devotion; pub mod draw_matters; pub mod enchantments; @@ -37,6 +38,7 @@ pub use aristocrats::AristocratsFeature; pub use artifacts::ArtifactsFeature; pub use blink::BlinkFeature; pub use control::ControlFeature; +pub use cycling::CyclingFeature; pub use devotion::DevotionFeature; pub use draw_matters::DrawMattersFeature; pub use enchantments::EnchantmentsFeature; @@ -93,6 +95,8 @@ pub struct DeckFeatures { pub graveyard_types: GraveyardTypesFeature, /// CR 121.1: "whenever you draw" payoff density (draw sources + engines). pub draw_matters: DrawMattersFeature, + /// CR 702.29: "cycling matters" payoff density (cyclers + on-cycle engines). + pub cycling: CyclingFeature, /// Declaration-derived: the deck's declared bracket tier. Unlike the /// other fields here, this is not structurally detected from card text — /// it is a per-deck declaration set at deck-analysis time from deck @@ -141,6 +145,7 @@ impl DeckFeatures { poison: poison::detect(deck), graveyard_types: graveyard_types::detect(deck), draw_matters: draw_matters::detect(deck), + cycling: cycling::detect(deck), bracket_tier: tier, } } diff --git a/crates/phase-ai/src/features/tests/cycling.rs b/crates/phase-ai/src/features/tests/cycling.rs new file mode 100644 index 0000000000..a747cc3ed0 --- /dev/null +++ b/crates/phase-ai/src/features/tests/cycling.rs @@ -0,0 +1,265 @@ +//! Unit tests for `features::cycling` — CR 702.29 "cycling matters" detection. +//! No `#[cfg(test)]` in SOURCE files; tests live here. + +use engine::game::DeckEntry; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, Effect, ModalChoice, QuantityExpr, TargetFilter, + TriggerDefinition, +}; +use engine::types::card::CardFace; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::keywords::{CyclingCost, Keyword}; +use engine::types::mana::ManaCost; +use engine::types::triggers::TriggerMode; + +use crate::features::cycling::*; + +fn face(name: &str, core: CoreType) -> CardFace { + CardFace { + name: name.to_string(), + card_type: CardType { + supertypes: Vec::new(), + core_types: vec![core], + subtypes: Vec::new(), + }, + ..Default::default() + } +} + +fn entry(card: CardFace, count: u32) -> DeckEntry { + DeckEntry { card, count } +} + +/// A cyclable card (CR 702.29a). +fn cycler(name: &str) -> CardFace { + let mut f = face(name, CoreType::Creature); + f.keywords = vec![Keyword::Cycling(CyclingCost::Mana(ManaCost::generic(2)))]; + f +} + +fn cycled_trigger( + mode: TriggerMode, + valid_card: Option, + valid_target: Option, +) -> TriggerDefinition { + let mut t = TriggerDefinition::new(mode); + if let Some(vc) = valid_card { + t = t.valid_card(vc); + } + if let Some(vt) = valid_target { + t = t.valid_target(vt); + } + t.execute(draw_a_card()) +} + +/// A supported, no-target payoff effect — the simplest thing a cycle trigger can +/// resolve to. +fn draw_a_card() -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ) +} + +/// Astral Drift shape: "whenever you cycle or discard a card, ..." — a broad, +/// controller-scoped engine on a permanent. +fn engine(name: &str) -> CardFace { + let mut f = face(name, CoreType::Enchantment); + f.triggers = vec![cycled_trigger(TriggerMode::CycledOrDiscarded, None, None)]; + f +} + +#[test] +fn empty_deck_produces_defaults() { + let f = detect(&[]); + assert_eq!(f.source_count, 0); + assert_eq!(f.payoff_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn vanilla_deck_not_registered() { + let f = detect(&[entry(face("Bear", CoreType::Creature), 20)]); + assert_eq!(f.source_count, 0); + assert_eq!(f.payoff_count, 0); + assert_eq!(f.commitment, 0.0); +} + +#[test] +fn detects_cycler_source() { + let f = detect(&[entry(cycler("Shefet Monitor"), 4)]); + assert_eq!(f.source_count, 4); +} + +#[test] +fn detects_engine_payoff() { + let f = detect(&[entry(engine("Astral Drift"), 3)]); + assert_eq!(f.payoff_count, 3); +} + +/// A "whenever you cycle" trigger with NO execute is a `TriggerNoExecute` no-op — +/// no value — so deck detection must not count it as an engine (else commitment +/// is inflated for an unsupported payoff). +#[test] +fn payoff_without_execute_is_not_counted() { + let mut f = face("No-op Engine", CoreType::Enchantment); + f.triggers = vec![TriggerDefinition::new(TriggerMode::CycledOrDiscarded)]; // no execute + assert_eq!(detect(&[entry(f, 3)]).payoff_count, 0); +} + +/// A "whenever you cycle" trigger whose execute is an unsupported +/// (`Effect::Unimplemented`) gap node likewise produces no value and is not +/// counted as an engine. +#[test] +fn payoff_with_unsupported_execute_is_not_counted() { + let mut f = face("Unsupported Engine", CoreType::Enchantment); + f.triggers = vec![ + TriggerDefinition::new(TriggerMode::CycledOrDiscarded).execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::unimplemented("cycling_payoff_test_gap", "unsupported payoff"), + )), + ]; + assert_eq!(detect(&[entry(f, 3)]).payoff_count, 0); +} + +/// A modal cycling payoff ("whenever you cycle a card, choose one — …") carries +/// a placeholder `Effect::Unimplemented` at its execute root by construction +/// (CR 700.2); its real effects live in `mode_abilities`. That placeholder is +/// not a parser gap, so the deck classifier must still count the card as an +/// engine — otherwise the unsupported-execute check would silently erase every +/// modal payoff from the axis. +#[test] +fn modal_payoff_is_counted() { + let mut f = face("Modal Engine", CoreType::Enchantment); + let mut execute = AbilityDefinition::new( + AbilityKind::Spell, + Effect::unimplemented("modal_placeholder", "choose one —"), + ); + execute.modal = Some(ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 2, + ..Default::default() + }); + execute.mode_abilities = vec![draw_a_card(), draw_a_card()]; + f.triggers = vec![TriggerDefinition::new(TriggerMode::CycledOrDiscarded).execute(execute)]; + assert_eq!(detect(&[entry(f, 3)]).payoff_count, 3); +} + +/// Control: the same modal shape whose modes are themselves unsupported produces +/// no value in any branch, so it is not an engine — the placeholder exemption +/// covers the modal root only, never the modes underneath it. +#[test] +fn modal_payoff_with_unsupported_modes_is_not_counted() { + let mut f = face("Modal Gap Engine", CoreType::Enchantment); + let mut execute = AbilityDefinition::new( + AbilityKind::Spell, + Effect::unimplemented("modal_placeholder", "choose one —"), + ); + execute.modal = Some(ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 2, + ..Default::default() + }); + let unsupported_mode = || { + AbilityDefinition::new( + AbilityKind::Spell, + Effect::unimplemented("cycling_payoff_test_gap", "unsupported mode"), + ) + }; + execute.mode_abilities = vec![unsupported_mode(), unsupported_mode()]; + f.triggers = vec![TriggerDefinition::new(TriggerMode::CycledOrDiscarded).execute(execute)]; + assert_eq!(detect(&[entry(f, 3)]).payoff_count, 0); +} + +/// A pure "when you cycle THIS card" self-bonus is a cyclable card with upside, +/// not a battlefield engine — it must not count as a payoff. +#[test] +fn self_cycle_bonus_is_not_a_payoff() { + let mut f = cycler("Radiant Smite"); + f.triggers = vec![cycled_trigger( + TriggerMode::Cycled, + Some(TargetFilter::SelfRef), + None, + )]; + let result = detect(&[entry(f, 4)]); + assert_eq!(result.source_count, 4, "still a cycler"); + assert_eq!(result.payoff_count, 0, "self-cycle bonus is not an engine"); +} + +/// An opponent-scoped "whenever an opponent cycles" punisher is not your payoff. +#[test] +fn opponent_scoped_trigger_ignored() { + let mut f = face("Punisher", CoreType::Enchantment); + f.triggers = vec![cycled_trigger( + TriggerMode::CycledOrDiscarded, + None, + Some(TargetFilter::Opponent), + )]; + assert_eq!(detect(&[entry(f, 2)]).payoff_count, 0); +} + +/// Calibration: a dedicated cycling shell (cyclers + engines) clears the floor. +#[test] +fn committed_cycling_deck_hits_floor() { + let deck = vec![ + entry(cycler("Cyc A"), 12), + entry(cycler("Cyc B"), 8), + entry(engine("Astral Drift"), 4), + entry(engine("Drannith Stinger"), 3), + entry(face("Plains", CoreType::Land), 24), + ]; + let f = detect(&deck); + assert!( + f.commitment > 0.6, + "committed cycling deck must clear 0.6, got {}", + f.commitment + ); +} + +/// Both pillars are mandatory: cyclers with no engine is just card smoothing. +#[test] +fn cyclers_without_engine_collapse() { + let deck = vec![ + entry(cycler("Cyc"), 20), + entry(face("Plains", CoreType::Land), 24), + ]; + assert_eq!(detect(&deck).commitment, 0.0); +} + +/// An engine with no cyclers never triggers → not a cycling deck. +#[test] +fn engine_without_cyclers_collapses() { + let deck = vec![ + entry(engine("Astral Drift"), 4), + entry(face("Plains", CoreType::Land), 24), + ]; + assert_eq!(detect(&deck).commitment, 0.0); +} + +#[test] +fn commitment_clamps_to_one() { + let deck = vec![entry(cycler("Cyc"), 40), entry(engine("Astral Drift"), 20)]; + assert!(detect(&deck).commitment <= 1.0); +} + +/// Boundary: a non-empty all-land deck (including cycling lands) has +/// `total_nonland == 0`. `commitment::density_per_60` guards that to `0.0`, so +/// commitment is a clean `0.0` — never `NaN`, which (since `NaN < FLOOR` is +/// false) would otherwise slip past `CyclingPayoffPolicy::activation`. +#[test] +fn all_land_deck_is_zero_not_nan() { + let mut cycling_land = face("Desert of the True", CoreType::Land); + cycling_land.keywords = vec![Keyword::Cycling(CyclingCost::Mana(ManaCost::generic(2)))]; + let deck = vec![ + entry(cycling_land, 20), + entry(face("Plains", CoreType::Land), 20), + ]; + let commitment = detect(&deck).commitment; + assert!(!commitment.is_nan(), "all-land deck must not produce NaN"); + assert_eq!(commitment, 0.0); +} diff --git a/crates/phase-ai/src/features/tests/mod.rs b/crates/phase-ai/src/features/tests/mod.rs index da91977ba0..ac46e4071b 100644 --- a/crates/phase-ai/src/features/tests/mod.rs +++ b/crates/phase-ai/src/features/tests/mod.rs @@ -3,6 +3,7 @@ pub mod artifacts; pub mod blink; +pub mod cycling; pub mod devotion; pub mod draw_matters; pub mod enchantments; diff --git a/crates/phase-ai/src/policies/anti_self_harm.rs b/crates/phase-ai/src/policies/anti_self_harm.rs index 032f6c4f23..01883657c7 100644 --- a/crates/phase-ai/src/policies/anti_self_harm.rs +++ b/crates/phase-ai/src/policies/anti_self_harm.rs @@ -3329,6 +3329,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); let config = AiConfig::default(); @@ -3442,6 +3443,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); let config = AiConfig::default(); diff --git a/crates/phase-ai/src/policies/cycling_payoff.rs b/crates/phase-ai/src/policies/cycling_payoff.rs new file mode 100644 index 0000000000..82550feeff --- /dev/null +++ b/crates/phase-ai/src/policies/cycling_payoff.rs @@ -0,0 +1,111 @@ +//! `CyclingPayoffPolicy` — makes an on-battlefield "whenever you cycle" engine a +//! reason the AI can see to cycle EAGERLY. +//! +//! ## The gap this closes +//! +//! CR 702.29a: cycling is card-neutral selection, so the generic activated- +//! ability prior undervalues it and [`CyclingDisciplinePolicy`](super::cycling_discipline) +//! only adds *patience* (don't cycle away a needed land); `self_cost_value` +//! explicitly defers cycling value (`self_cost_cycling_deferred`). Neither sees +//! the upside: with an engine like Astral Drift or Drannith Stinger on the +//! battlefield (CR 702.29c/d), every cycle is a repeatable value trigger — exile +//! a creature, ping each opponent, draw. This policy adds that positive signal, +//! which composes with the discipline penalty so a payoff deck cycles into its +//! engine while a smoothing-only deck stays patient. +//! +//! ## Performance +//! +//! `verdict()` runs per candidate per search node. The card-local check — the +//! candidate is a `Cycling`-tagged activation — runs FIRST and rejects every +//! other activation. Only a confirmed cycling activation pays for the +//! battlefield engine scan (a structural trigger match over each permanent's +//! live `trigger_definitions`), and only in a deck whose `activation` floor is +//! already cleared. Each structurally matching engine is then confirmed LIVE by +//! [`engine::game::triggers::hypothetical_trigger_fireable`] — the engine-owned +//! authority for "could this trigger still fire AND resolve to an effect?", +//! reusing the live pipeline's own constraint check plus a CR 603.3d target +//! preflight (CR 603.2-603.4). The policy holds no eligibility rules of its own. +//! A no-target payoff like Drannith Stinger ("deals damage to each opponent") +//! still qualifies: it has no target slot to satisfy. + +use engine::types::ability::AbilityTag; +use engine::types::game_state::GameState; +use engine::types::player::PlayerId; + +use engine::game::triggers::hypothetical_trigger_fireable; + +use crate::features::cycling::{is_cycle_payoff_trigger, CYCLING_PAYOFF_FLOOR}; +use crate::features::DeckFeatures; + +use super::context::PolicyContext; +use super::registry::{DecisionKind, PolicyId, PolicyReason, PolicyVerdict, TacticalPolicy}; + +pub struct CyclingPayoffPolicy; + +/// Cap on how many simultaneous engines are rewarded, so a stacked board can't +/// push a single cycle into the critical band. +const MAX_REWARDED_ENGINES: usize = 3; + +impl TacticalPolicy for CyclingPayoffPolicy { + fn id(&self) -> PolicyId { + PolicyId::CyclingPayoff + } + + fn decision_kinds(&self) -> &'static [DecisionKind] { + &[DecisionKind::ActivateAbility] + } + + fn activation( + &self, + features: &DeckFeatures, + _state: &GameState, + _player: PlayerId, + ) -> Option { + if features.cycling.commitment < CYCLING_PAYOFF_FLOOR { + None + } else { + Some(features.cycling.commitment) + } + } + + fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { + // Card-local first: only a Cycling activation is in scope (CR 702.29a). + let Some(ability) = ctx.effective_activated_ability() else { + return PolicyVerdict::neutral(PolicyReason::new("cycling_payoff_na")); + }; + if ability.ability_tag != Some(AbilityTag::Cycling) { + return PolicyVerdict::neutral(PolicyReason::new("cycling_payoff_na")); + } + + // Only now pay for the battlefield scan. Re-classify each permanent the + // AI controls STRUCTURALLY against its live `trigger_definitions` (CR + // 702.29c/d) — a name match is not enough, the object must actually + // carry the "whenever you cycle" trigger to produce value. + let engines = ctx + .state + .battlefield + .iter() + .filter(|id| { + ctx.state.objects.get(id).is_some_and(|obj| { + obj.controller == ctx.ai_player + && obj.trigger_definitions.iter_unchecked().any(|entry| { + is_cycle_payoff_trigger(&entry.definition) + && hypothetical_trigger_fireable(ctx.state, obj, entry) + }) + }) + }) + .count(); + if engines == 0 { + return PolicyVerdict::neutral(PolicyReason::new("cycling_payoff_no_engine")); + } + + // Each active engine turns this cycle into a value trigger — roughly a + // card-equivalent apiece, capped so one cycle stays a preference, not a + // game-deciding swing. + let rewarded = engines.min(MAX_REWARDED_ENGINES) as f64; + PolicyVerdict::score( + ctx.config.policy_penalties.cycling_payoff_bonus * rewarded, + PolicyReason::new("cycling_payoff_engine_active").with_fact("engines", engines as i64), + ) + } +} diff --git a/crates/phase-ai/src/policies/evasion_removal_priority.rs b/crates/phase-ai/src/policies/evasion_removal_priority.rs index 676438a205..ca1ce5d829 100644 --- a/crates/phase-ai/src/policies/evasion_removal_priority.rs +++ b/crates/phase-ai/src/policies/evasion_removal_priority.rs @@ -629,6 +629,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, })); let config = AiConfig::default(); let slot = TargetSelectionSlot { diff --git a/crates/phase-ai/src/policies/mod.rs b/crates/phase-ai/src/policies/mod.rs index 0061f7816e..750ef5aacf 100644 --- a/crates/phase-ai/src/policies/mod.rs +++ b/crates/phase-ai/src/policies/mod.rs @@ -16,6 +16,7 @@ mod control_change_awareness; pub(crate) mod copy_value; mod crew_timing; mod cycling_discipline; +mod cycling_payoff; mod devotion; mod downside_awareness; mod draw_payoff; diff --git a/crates/phase-ai/src/policies/registry.rs b/crates/phase-ai/src/policies/registry.rs index 46a33a3cee..63950af2a6 100644 --- a/crates/phase-ai/src/policies/registry.rs +++ b/crates/phase-ai/src/policies/registry.rs @@ -146,6 +146,9 @@ pub enum PolicyId { SelfBounceTarget, /// CR 121.1: reward drawing into an on-battlefield "whenever you draw" engine. DrawPayoff, + /// CR 702.29c/d: reward cycling into an on-battlefield "whenever you cycle" + /// engine. + CyclingPayoff, } /// Coarse routing kind for a candidate decision. Each policy declares which @@ -402,6 +405,7 @@ impl Default for PolicyRegistry { Box::new(LoopShortcutPolicy), Box::new(super::self_bounce_target::SelfBounceTargetPolicy), Box::new(super::draw_payoff::DrawPayoffPolicy), + Box::new(super::cycling_payoff::CyclingPayoffPolicy), ]; let mut by_kind: HashMap> = HashMap::new(); for (idx, policy) in policies.iter().enumerate() { diff --git a/crates/phase-ai/src/policies/tests/cycling_payoff.rs b/crates/phase-ai/src/policies/tests/cycling_payoff.rs new file mode 100644 index 0000000000..9c18626a68 --- /dev/null +++ b/crates/phase-ai/src/policies/tests/cycling_payoff.rs @@ -0,0 +1,991 @@ +//! Unit tests for `policies::cycling_payoff` — CR 702.29c/d "cycling matters" +//! payoff policy. No `#[cfg(test)]` in SOURCE files; tests live here. +//! +//! Direct-`verdict` tests cover each branch; a registry-routed regression +//! exercises the production seam (registration + `ActivateAbility` routing), +//! following the poison policy's pattern. + +use std::sync::Arc; + +use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; +use engine::game::ability_utils::{build_resolved_from_def, build_target_slots}; +use engine::game::zones::create_object; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, Effect, ModalChoice, QuantityExpr, TargetFilter, + TriggerConstraint, TriggerDefinition, TypedFilter, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::format::FormatConfig; +use engine::types::game_state::{GameState, TargetSelectionConstraint, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::keywords::{CyclingCost, Keyword}; +use engine::types::mana::ManaCost; +use engine::types::player::PlayerId; +use engine::types::triggers::TriggerMode; +use engine::types::zones::Zone; + +use crate::config::AiConfig; +use crate::context::AiContext; +use crate::features::cycling::{CyclingFeature, CYCLING_PAYOFF_FLOOR}; +use crate::features::DeckFeatures; +use crate::policies::context::{PolicyContext, SearchDepth}; +use crate::policies::cycling_payoff::*; +use crate::policies::registry::{ + PolicyId, PolicyReason, PolicyRegistry, PolicyVerdict, TacticalPolicy, +}; +use crate::session::AiSession; + +const AI: PlayerId = PlayerId(0); +const ENGINE_NAME: &str = "Astral Drift"; + +fn state() -> GameState { + GameState::new(FormatConfig::standard(), 2, 42) +} + +/// A cyclable card in hand whose synthesized Cycling ability is the activation. +fn cycler(state: &mut GameState) -> ObjectId { + let kw = Keyword::Cycling(CyclingCost::Mana(ManaCost::generic(2))); + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, "Cyc".to_string(), Zone::Hand); + let ability = engine::database::synthesis::cycling_ability_for_keyword(&kw) + .expect("cycling keyword must synthesize an activated ability"); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + Arc::make_mut(&mut obj.abilities).push(ability); + id +} + +/// A permanent the AI controls, named `ENGINE_NAME`, that carries `trigger` +/// live `trigger_definitions` (or none when `trigger` is `None` — the name-only +/// impostor case). +fn permanent_with_trigger(state: &mut GameState, trigger: Option) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + AI, + ENGINE_NAME.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + if let Some(trigger) = trigger { + obj.trigger_definitions.push(trigger); + } + id +} + +/// Astral Drift shape: a live "whenever you cycle or discard a card" engine. +fn engine_on_battlefield(state: &mut GameState) { + permanent_with_trigger(state, Some(cycle_trigger(TargetFilter::Any))); +} + +/// A controller-scoped `CycledOrDiscarded` trigger whose effect targets +/// `target` (use `TargetFilter::Controller` for a no-target payoff shape). +fn cycle_trigger(target: TargetFilter) -> TriggerDefinition { + TriggerDefinition::new(TriggerMode::CycledOrDiscarded).execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target, + }, + )) +} + +fn session(commitment: f32) -> AiSession { + let features = DeckFeatures { + cycling: CyclingFeature { + source_count: 18, + payoff_count: 4, + commitment, + }, + ..Default::default() + }; + let mut session = AiSession::empty(); + session.features.insert(AI, features); + session +} + +fn context(config: &AiConfig, session: AiSession) -> AiContext { + let mut context = AiContext::empty(&config.weights); + context.session = Arc::new(session); + context.player = AI; + context +} + +fn activate(source_id: ObjectId) -> CandidateAction { + CandidateAction { + action: GameAction::ActivateAbility { + source_id, + ability_index: 0, + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Ability), + } +} + +fn ctx<'a>( + state: &'a GameState, + candidate: &'a CandidateAction, + decision: &'a AiDecisionContext, + context: &'a AiContext, + config: &'a AiConfig, +) -> PolicyContext<'a> { + PolicyContext { + state, + decision, + candidate, + ai_player: AI, + config, + context, + cast_facts: None, + search_depth: SearchDepth::Root, + } +} + +fn score_of(verdict: PolicyVerdict) -> (f64, PolicyReason) { + match verdict { + PolicyVerdict::Score { delta, reason } => (delta, reason), + PolicyVerdict::Reject { reason } => panic!("unexpected Reject: {reason:?}"), + } +} + +// ─── activation ────────────────────────────────────────────────────────────── + +#[test] +fn activation_opts_out_below_floor() { + let mut features = DeckFeatures::default(); + features.cycling.commitment = CYCLING_PAYOFF_FLOOR - 0.01; + assert!(CyclingPayoffPolicy + .activation(&features, &state(), AI) + .is_none()); +} + +#[test] +fn activation_opts_in_above_floor() { + let mut features = DeckFeatures::default(); + features.cycling.commitment = 0.9; + assert_eq!( + CyclingPayoffPolicy.activation(&features, &state(), AI), + Some(0.9) + ); +} + +// ─── verdict ───────────────────────────────────────────────────────────────── + +#[test] +fn rewards_cycling_with_an_active_engine() { + let config = AiConfig::default(); + let mut st = state(); + engine_on_battlefield(&mut st); + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_engine_active"); + assert!( + delta > 0.0, + "cycling into an engine must be rewarded, got {delta}" + ); +} + +#[test] +fn neutral_without_an_engine_on_board() { + let config = AiConfig::default(); + let mut st = state(); + // Engine known to the deck, but none is on the battlefield. + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +#[test] +fn neutral_for_a_non_cycling_action() { + let config = AiConfig::default(); + let st = state(); + let context = context(&config, session(0.9)); + // A cast candidate is not an activated ability at all. + let candidate = CandidateAction { + action: GameAction::ActivateAbility { + source_id: ObjectId(999), + ability_index: 0, + }, + metadata: ActionMetadata::for_actor(Some(AI), TacticalClass::Ability), + }; + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_na"); + assert_eq!(delta, 0.0); +} + +/// [MED review] A permanent that merely SHARES the engine's name but carries no +/// live cycle trigger must not be rewarded — detection is structural over +/// `trigger_definitions`, not name-based. +#[test] +fn name_only_impostor_without_a_live_trigger_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + permanent_with_trigger(&mut st, None); // named "Astral Drift", no trigger + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +/// A no-target payoff (Drannith Stinger shape — its on-cycle effect hits each +/// opponent, choosing nothing) must still be rewarded; the policy checks the +/// live trigger, not target legality. +#[test] +fn no_target_payoff_still_rewards() { + let config = AiConfig::default(); + let mut st = state(); + permanent_with_trigger(&mut st, Some(cycle_trigger(TargetFilter::Controller))); + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_engine_active"); + assert!( + delta > 0.0, + "no-target payoff must still reward, got {delta}" + ); +} + +/// A once-per-turn "whenever you cycle" engine (Valiant Rescuer shape). +fn once_per_turn_engine(state: &mut GameState) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + AI, + ENGINE_NAME.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.trigger_definitions + .push(cycle_trigger(TargetFilter::Any).constraint(TriggerConstraint::OncePerTurn)); + id +} + +/// [MED review] A once-per-turn engine that has already fired this turn cannot +/// fire again (CR 603.4), so a second cycle earns nothing — the policy consults +/// the fired-trigger ledger, not just the structural trigger shape. +#[test] +fn rate_limited_engine_already_fired_this_turn_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + let engine_id = once_per_turn_engine(&mut st); + // Mark its trigger as already fired this turn via the ledger authority. + let key = { + let obj = st.objects.get(&engine_id).unwrap(); + let entry = obj.trigger_definitions.iter_unchecked().next().unwrap(); + obj.trigger_definition_ref(entry) + }; + st.triggers_fired_this_turn.insert(key); + + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +/// Control: the same once-per-turn engine that has NOT fired yet still rewards. +#[test] +fn rate_limited_engine_not_yet_fired_rewards() { + let config = AiConfig::default(); + let mut st = state(); + once_per_turn_engine(&mut st); + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_engine_active"); + assert!(delta > 0.0, "an unfired once-per-turn engine still rewards"); +} + +/// An engine trigger with `constraint` on the AI's own permanent. +fn engine_with_constraint(state: &mut GameState, constraint: TriggerConstraint) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + AI, + ENGINE_NAME.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.trigger_definitions + .push(cycle_trigger(TargetFilter::Any).constraint(constraint)); + id +} + +/// A once-per-game engine already fired this game earns nothing more. +#[test] +fn once_per_game_engine_already_fired_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + let engine_id = engine_with_constraint(&mut st, TriggerConstraint::OncePerGame); + let key = { + let obj = st.objects.get(&engine_id).unwrap(); + let entry = obj.trigger_definitions.iter_unchecked().next().unwrap(); + obj.trigger_definition_ref(entry) + }; + st.triggers_fired_this_game.insert(key); + + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +/// An `OnlyDuringYourTurn` engine on the opponent's turn cannot fire, so cycling +/// at instant speed during their turn earns nothing. +#[test] +fn only_during_your_turn_engine_is_neutral_off_turn() { + let config = AiConfig::default(); + let mut st = state(); + st.active_player = PlayerId(1); + engine_with_constraint(&mut st, TriggerConstraint::OnlyDuringYourTurn); + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +/// "Deal 2 damage to target creature" — exactly one MANDATORY creature target. +fn damage_target_creature() -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 2 }, + target: TargetFilter::Typed(TypedFilter::creature()), + damage_source: None, + excess: None, + }, + ) +} + +/// A "whenever you cycle, deal 2 damage to TARGET creature" payoff — value +/// depends on a legal creature target existing (CR 603.3d). Distinct from the +/// no-target `cycle_trigger` (a `Draw` needs no target slot). +fn targeted_cycle_trigger() -> TriggerDefinition { + TriggerDefinition::new(TriggerMode::CycledOrDiscarded).execute(damage_target_creature()) +} + +/// The same payoff with a sub-ability chain, so the execute carries TWO +/// mandatory creature target slots — the shape the cheap single-slot check +/// cannot decide, which must fall through to the engine's full +/// legal-assignment authority rather than being assumed satisfiable. +fn two_target_cycle_trigger() -> TriggerDefinition { + TriggerDefinition::new(TriggerMode::CycledOrDiscarded) + .execute(damage_target_creature().sub_ability(damage_target_creature())) +} + +/// How many target slots the engine builds for the payoff permanent's trigger +/// execute — used to assert a fixture's target SHAPE, so a slot-count change +/// surfaces as a precondition failure instead of a silently weakened test. +fn engine_execute_target_slot_count(state: &GameState, engine_id: ObjectId) -> usize { + let obj = state + .objects + .get(&engine_id) + .expect("payoff permanent must be on the battlefield"); + let entry = obj + .trigger_definitions + .iter_unchecked() + .next() + .expect("payoff permanent must carry a trigger"); + let execute = entry + .definition + .execute + .as_deref() + .expect("payoff trigger must have an execute"); + let resolved = build_resolved_from_def(execute, obj.id, obj.controller); + build_target_slots(state, &resolved) + .expect("target slots must build") + .len() +} + +/// Puts an opponent creature on the battlefield — a legal "target creature". +fn add_opponent_creature(state: &mut GameState) { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + PlayerId(1), + "Grizzly Bears".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&id) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); +} + +/// CR 603.3d: a mandatory-target cycling payoff with no legal target on the board +/// cannot resolve to an effect, so the engine's `hypothetical_trigger_fireable` +/// target-legality preflight rejects it and no bonus is awarded. `permanent_with_trigger` +/// builds an Enchantment, so an empty board truly has no creature to target. +#[test] +fn targeted_payoff_with_no_legal_target_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + permanent_with_trigger(&mut st, Some(targeted_cycle_trigger())); + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +/// Control: once a legal creature target exists, the same targeted payoff is live +/// and cycling is rewarded. +#[test] +fn targeted_payoff_with_a_legal_target_rewards() { + let config = AiConfig::default(); + let mut st = state(); + permanent_with_trigger(&mut st, Some(targeted_cycle_trigger())); + add_opponent_creature(&mut st); + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_engine_active"); + assert!(delta > 0.0); +} + +/// CR 603.3d, multi-slot shape: a payoff whose execute needs TWO mandatory +/// creature targets earns nothing on a creatureless board. This is the shape +/// the cheap single-slot check reports as UNDECIDABLE; answering that +/// optimistically — as an `unwrap_or(true)` would — credits a trigger that +/// cannot resolve to an effect. Legality is instead settled by the engine's own +/// target authority, which reports no legal targets for the mandatory slots. +#[test] +fn multi_target_payoff_with_no_legal_target_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + permanent_with_trigger(&mut st, Some(two_target_cycle_trigger())); + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +/// Control for the pair above: with creatures available the identical two-target +/// payoff is live and rewarded — so the multi-slot path RESOLVES legality rather +/// than rejecting every shape it cannot cheaply decide. Also pins the fixture's +/// target shape, so a slot-count change surfaces here as a precondition failure +/// instead of silently degrading the negative case into a single-slot test. +#[test] +fn multi_target_payoff_with_legal_targets_rewards() { + let config = AiConfig::default(); + let mut st = state(); + let engine_id = permanent_with_trigger(&mut st, Some(two_target_cycle_trigger())); + add_opponent_creature(&mut st); + add_opponent_creature(&mut st); + assert_eq!( + engine_execute_target_slot_count(&st, engine_id), + 2, + "fixture must carry TWO mandatory target slots" + ); + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_engine_active"); + assert!(delta > 0.0); +} + +/// A required-modal payoff: "whenever you cycle a card, choose one — deal 2 +/// damage to target creature; or deal 2 damage to target creature". A modal +/// execute keeps a placeholder at its root and its real effects — and therefore +/// its target slots — in `mode_abilities`, so the root slot walk sees no targets +/// at all; the modal choice authority is what settles legality. +fn modal_all_target_required_trigger() -> TriggerDefinition { + let mut execute = AbilityDefinition::new( + AbilityKind::Spell, + Effect::unimplemented("modal_placeholder", "choose one —"), + ); + execute.modal = Some(ModalChoice { + min_choices: 1, + max_choices: 1, + mode_count: 2, + ..Default::default() + }); + execute.mode_abilities = vec![damage_target_creature(), damage_target_creature()]; + TriggerDefinition::new(TriggerMode::CycledOrDiscarded).execute(execute) +} + +/// CR 603.3c: a required "choose one" payoff whose every mode needs a creature +/// target earns nothing on a creatureless board — no mode can legally be chosen, +/// so the live trigger is removed from the stack rather than producing a payoff +/// (`DroppedNoLegalMode`, proven against the real dispatch in +/// `triggers::tests::modal_trigger_all_target_required_modes_no_targets_is_dropped`). +/// The preflight asks the same `resolve_legal_modal_choice` authority the live +/// dispatch asks, so the two cannot disagree. +#[test] +fn modal_payoff_with_no_legal_mode_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + permanent_with_trigger(&mut st, Some(modal_all_target_required_trigger())); + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +/// Control for the pair above: one legal creature target makes a mode choosable, +/// so the identical modal payoff is live and cycling is rewarded. Without this +/// control, rejecting every modal shape outright would also pass the negative +/// case. +#[test] +fn modal_payoff_with_a_legal_mode_rewards() { + let config = AiConfig::default(); + let mut st = state(); + permanent_with_trigger(&mut st, Some(modal_all_target_required_trigger())); + add_opponent_creature(&mut st); + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_engine_active"); + assert!(delta > 0.0); +} + +/// A modal with no `mode_abilities` has nothing to stand in for its placeholder +/// root, so the placeholder itself is what would resolve — an unsupported no-op +/// that is not an engine, even with a legal target on the board. +#[test] +fn modal_payoff_without_modes_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + let mut trigger = modal_all_target_required_trigger(); + trigger + .execute + .as_deref_mut() + .expect("fixture trigger must carry an execute") + .mode_abilities + .clear(); + permanent_with_trigger(&mut st, Some(trigger)); + add_opponent_creature(&mut st); // a legal target exists — choosable modes do not + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +/// A `MaxTimesPerTurn { max }` engine that has fired fewer than `max` times this +/// turn can still fire, so cycling is rewarded — the shared engine authority reads +/// the live `trigger_fire_counts_this_turn` ledger rather than rejecting the +/// constraint categorically. +#[test] +fn max_times_per_turn_below_cap_rewards() { + let config = AiConfig::default(); + let mut st = state(); + let engine_id = engine_with_constraint(&mut st, TriggerConstraint::MaxTimesPerTurn { max: 2 }); + let key = { + let obj = st.objects.get(&engine_id).unwrap(); + let entry = obj.trigger_definitions.iter_unchecked().next().unwrap(); + obj.trigger_definition_ref(entry) + }; + st.trigger_fire_counts_this_turn.insert(key, 1); // 1 < 2 → can still fire + + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_engine_active"); + assert!(delta > 0.0); +} + +/// Control: the same engine that has already fired `max` times this turn cannot +/// fire again, so cycling earns nothing. +#[test] +fn max_times_per_turn_at_cap_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + let engine_id = engine_with_constraint(&mut st, TriggerConstraint::MaxTimesPerTurn { max: 2 }); + let key = { + let obj = st.objects.get(&engine_id).unwrap(); + let entry = obj.trigger_definitions.iter_unchecked().next().unwrap(); + obj.trigger_definition_ref(entry) + }; + st.trigger_fire_counts_this_turn.insert(key, 2); // 2 == max → exhausted + + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +/// A Class-enchantment cycling engine at `class_level` whose level-gated +/// "whenever you cycle" payoff fires only while the Class is at `required_level` +/// (CR 716). The shared hypothetical authority reads the level from the source +/// context. +fn class_engine(state: &mut GameState, class_level: u8, required_level: u8) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + AI, + ENGINE_NAME.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.class_level = Some(class_level); + obj.trigger_definitions + .push( + cycle_trigger(TargetFilter::Any).constraint(TriggerConstraint::AtClassLevel { + level: required_level, + }), + ); + id +} + +/// CR 716: an `AtClassLevel` payoff at the required level is live — the shared +/// hypothetical authority passes the source context, so the class level is read +/// correctly rather than treated as absent. +#[test] +fn at_class_level_engine_at_required_level_rewards() { + let config = AiConfig::default(); + let mut st = state(); + class_engine(&mut st, 2, 2); // at level 2, needs level 2 + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_engine_active"); + assert!(delta > 0.0); +} + +/// Control: the same Class engine at a DIFFERENT level cannot fire its +/// level-gated payoff, so cycling earns nothing. +#[test] +fn at_class_level_engine_at_wrong_level_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + class_engine(&mut st, 1, 2); // at level 1, but the payoff needs level 2 + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = + score_of(CyclingPayoffPolicy.verdict(&ctx(&st, &candidate, &decision, &context, &config))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +// ─── executable-support + constrained-target contract ─────────────────────── + +fn creature_filter() -> TargetFilter { + TargetFilter::Typed( + TypedFilter::default().with_type(engine::types::ability::TypeFilter::Creature), + ) +} + +/// Adds an AI-controlled creature to the battlefield. +fn add_ai_creature(state: &mut GameState) { + let card_id = CardId(state.next_object_id); + let id = create_object(state, card_id, AI, "Bear".to_string(), Zone::Battlefield); + state + .objects + .get_mut(&id) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); +} + +/// A "whenever you cycle, exchange control of two permanents controlled by +/// DIFFERENT players" engine — the execute carries a `DifferentObjectControllers` +/// cross-target constraint (CR 115.1) the preflight must honor. +fn constrained_two_target_engine(state: &mut GameState) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + AI, + ENGINE_NAME.to_string(), + Zone::Battlefield, + ); + let mut execute = AbilityDefinition::new( + AbilityKind::Spell, + Effect::ExchangeControl { + target_a: creature_filter(), + target_b: creature_filter(), + }, + ); + execute.target_constraints = vec![TargetSelectionConstraint::DifferentObjectControllers]; + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.trigger_definitions + .push(TriggerDefinition::new(TriggerMode::CycledOrDiscarded).execute(execute)); + id +} + +/// CR 115.1 + CR 603.3d: two permanents controlled by the SAME player cannot +/// satisfy the engine's `DifferentObjectControllers` constraint, so the trigger +/// has no legal assignment and is not a live payoff. +#[test] +fn constrained_two_target_engine_same_controller_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + constrained_two_target_engine(&mut st); + add_ai_creature(&mut st); + add_ai_creature(&mut st); // both mine → different-controllers can't be met + let source = cycler(&mut st); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = score_of(CyclingPayoffPolicy.verdict(&ctx( + &st, + &candidate, + &decision, + &context(&config, session(0.9)), + &config, + ))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +/// Control: one permanent per player satisfies `DifferentObjectControllers`, so +/// the constrained engine is live and cycling is rewarded. +#[test] +fn constrained_two_target_engine_different_controllers_rewards() { + let config = AiConfig::default(); + let mut st = state(); + constrained_two_target_engine(&mut st); + add_ai_creature(&mut st); + add_opponent_creature(&mut st); // one each → constraint satisfiable + let source = cycler(&mut st); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = score_of(CyclingPayoffPolicy.verdict(&ctx( + &st, + &candidate, + &decision, + &context(&config, session(0.9)), + &config, + ))); + assert_eq!(reason.kind, "cycling_payoff_engine_active"); + assert!(delta > 0.0); +} + +/// A "whenever you cycle" trigger with NO execute resolves to a `TriggerNoExecute` +/// no-op — no payoff — so it is not a live engine. +#[test] +fn no_execute_engine_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + permanent_with_trigger( + &mut st, + Some(TriggerDefinition::new(TriggerMode::CycledOrDiscarded)), + ); + let source = cycler(&mut st); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = score_of(CyclingPayoffPolicy.verdict(&ctx( + &st, + &candidate, + &decision, + &context(&config, session(0.9)), + &config, + ))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +/// A "whenever you cycle" trigger whose execute is an unsupported +/// (`Effect::Unimplemented`) gap node produces no payoff, so it is not credited. +#[test] +fn unsupported_execute_engine_is_neutral() { + let config = AiConfig::default(); + let mut st = state(); + permanent_with_trigger( + &mut st, + Some( + TriggerDefinition::new(TriggerMode::CycledOrDiscarded).execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::unimplemented("cycling_payoff_test_gap", "unsupported payoff"), + )), + ), + ); + let source = cycler(&mut st); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = score_of(CyclingPayoffPolicy.verdict(&ctx( + &st, + &candidate, + &decision, + &context(&config, session(0.9)), + &config, + ))); + assert_eq!(reason.kind, "cycling_payoff_no_engine"); + assert_eq!(delta, 0.0); +} + +// ─── production seam (registry routing) ───────────────────────────────────── + +#[test] +fn registry_registers_the_policy() { + assert!(PolicyRegistry::default().has_policy(PolicyId::CyclingPayoff)); +} + +/// End-to-end: an `ActivateAbility` on a cycler classifies to +/// `DecisionKind::ActivateAbility`, the policy declares that kind and clears its +/// activation floor, and the engine-active reward comes out of the registry. +#[test] +fn registry_routes_cycling_activation_to_the_policy() { + let config = AiConfig::default(); + let mut st = state(); + engine_on_battlefield(&mut st); + let source = cycler(&mut st); + let context = context(&config, session(0.9)); + let candidate = activate(source); + let decision = AiDecisionContext { + waiting_for: WaitingFor::Priority { player: AI }, + candidates: vec![candidate.clone()], + }; + let (delta, reason) = PolicyRegistry::default() + .verdicts(&ctx(&st, &candidate, &decision, &context, &config)) + .into_iter() + .find(|(id, _)| *id == PolicyId::CyclingPayoff) + .map(|(_, v)| score_of(v)) + .expect("the cycling activation must reach the policy through the registry"); + assert_eq!(reason.kind, "cycling_payoff_engine_active"); + assert!(delta > 0.0, "routed reward must be positive, got {delta}"); +} diff --git a/crates/phase-ai/src/policies/tests/mod.rs b/crates/phase-ai/src/policies/tests/mod.rs index e455010e7a..38b7a31c24 100644 --- a/crates/phase-ai/src/policies/tests/mod.rs +++ b/crates/phase-ai/src/policies/tests/mod.rs @@ -3,6 +3,7 @@ pub mod activation_marker_lint; pub mod artifact_synergy; pub mod blink_payoff; +pub mod cycling_payoff; pub mod devotion; pub mod draw_payoff; pub mod effect_classify_snapshot; diff --git a/crates/server-core/src/filter.rs b/crates/server-core/src/filter.rs index 05aac9ebd0..2df369702c 100644 --- a/crates/server-core/src/filter.rs +++ b/crates/server-core/src/filter.rs @@ -506,6 +506,7 @@ mod tests { may_trigger_origin: None, subject_match_count: None, die_result: None, + announced_modal_choice: None, }; PendingTriggerContext { pending,