diff --git a/crates/engine/src/analysis/ability_graph.rs b/crates/engine/src/analysis/ability_graph.rs index bbf63ae952..81c7dec2f0 100644 --- a/crates/engine/src/analysis/ability_graph.rs +++ b/crates/engine/src/analysis/ability_graph.rs @@ -892,6 +892,10 @@ fn effect_projection(effect: &Effect) -> Projection { | Effect::DoublePT { .. } | Effect::DoublePTAll { .. } | Effect::MoveCounters { .. } + // CR 122.1 + CR 603.2c: the reproduced counter kind is event-derived (not + // statically known), so it projects onto no fixed resource axis — like + // `MoveCounters`, it is Unmodeled. + | Effect::ReproduceEventCounters { .. } | Effect::Animate { .. } | Effect::ReturnAsAura { .. } | Effect::RegisterBending { .. } diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 08f79eeb71..283898ea36 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2868,6 +2868,9 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::GrantCastingPermission { target, .. } | Effect::AddTargetReplacement { target, .. } | Effect::DiscardCard { target, .. } + // CR 122.1 + CR 603.2c: only the reproduction target carries a legacy tag; + // the per-kind magnitude is a plain enum with no batch-prompt semantics. + | Effect::ReproduceEventCounters { target, .. } | Effect::Animate { target, .. } => legacy_target_filter(target), Effect::PutOnTopOrBottom { target, chooser } => { @@ -4391,6 +4394,18 @@ fn rw_effect( } (p, sc) } + // CR 122.1 + CR 603.2c + CR 608.2h: writes ObjectCounters on the target; + // the reproduced kind+count multiset is read from the triggering event + // batch (`state.current_trigger_events`) — a live event-context read, not + // a read of any object's counter map. + Effect::ReproduceEventCounters { + target, + per_kind_count: _, + } => { + let (mut p, sc) = obj(StateKind::ObjectCounters, target); + p.merge(reads_event_live()); + (p, sc) + } Effect::Bolster { count } => { let mut p = ext_write(StateKind::ObjectCounters); // Untargeted external counter write ⇒ census Any (fail-closed, §2). diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 1e2bde8df2..29ee2775b1 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -991,6 +991,17 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { acc = acc.or(scan_target_filter(target, target_ctx, mode)); acc } + // CR 122.1 + CR 603.2c: the per-kind magnitude is event-derived (not a + // `QuantityExpr`), so only the reproduction target is scanned; mirrors + // `MultiplyCounter`. + Effect::ReproduceEventCounters { + target, + per_kind_count: _, + } => { + let mut acc = Axes::NONE; + acc = acc.or(scan_target_filter(target, target_ctx, mode)); + acc + } Effect::Animate { .. } => Axes::CONSERVATIVE, Effect::ReturnAsAura { .. } => Axes::CONSERVATIVE, Effect::RegisterBending { kind: _ } => Axes::NONE, @@ -5379,6 +5390,7 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext { | Effect::HideawayConceal { .. } | Effect::ChooseCard { .. } | Effect::PutCounter { .. } + | Effect::ReproduceEventCounters { .. } | Effect::DoublePT { .. } | Effect::MoveCounters { .. } | Effect::Animate { .. } @@ -5781,6 +5793,7 @@ fn effect_census_role(e: &Effect) -> CensusRole { | Effect::HideawayConceal { .. } | Effect::ChooseCard { .. } | Effect::PutCounter { .. } + | Effect::ReproduceEventCounters { .. } | Effect::DoublePT { .. } | Effect::MoveCounters { .. } | Effect::Animate { .. } @@ -6019,6 +6032,7 @@ pub(crate) fn effect_is_randomness_bearing(e: &Effect) -> bool { | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::PutCounter { .. } + | Effect::ReproduceEventCounters { .. } | Effect::PutCounterAll { .. } | Effect::MultiplyCounter { .. } | Effect::DoublePT { .. } diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 454dc6368e..012d558a70 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -2609,6 +2609,13 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { )); d.push(("target".into(), fmt_target(target))); } + Effect::ReproduceEventCounters { + target, + per_kind_count, + } => { + d.push(("reproduce counters".into(), format!("{per_kind_count:?}"))); + d.push(("target".into(), fmt_target(target))); + } Effect::RemoveCounter { counter_type, count, diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index e01a3e48ed..72deb5c8dc 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -4,8 +4,8 @@ use crate::game::game_object::GameObject; use crate::game::replacement::{self, ReplacementResult}; use crate::types::ability::{ AbilityTag, CounterMoveSelection, CounterTransferMode, DelayedTriggerCondition, Duration, - Effect, EffectError, EffectKind, QuantityExpr, ResolvedAbility, TargetChoiceTiming, - TargetFilter, TargetRef, + Effect, EffectError, EffectKind, EventCounterReproductionCount, QuantityExpr, ResolvedAbility, + TargetChoiceTiming, TargetFilter, TargetRef, }; #[cfg(test)] use crate::types::counter::parse_counter_type; @@ -891,6 +891,9 @@ pub(crate) fn apply_counter_addition( object_id, counter_type, count, + // CR 122.1 + CR 603.2c: record who placed the counters so actor-gated + // "whenever you/an opponent put counters" triggers can match. + actor, }); } @@ -1473,7 +1476,8 @@ fn emit_evolved_event_for_counter_addition( GameEvent::CounterAdded { object_id: added_to, counter_type: CounterType::Plus1Plus1, - count + count, + .. } if *added_to == object_id && *count > 0 ) }); @@ -1482,6 +1486,98 @@ fn emit_evolved_event_for_counter_addition( } } +/// CR 122.1 + CR 603.2c + CR 608.2h: Reproduce onto the effect's target(s) the +/// counters that the triggering counter-placement event just put onto the +/// recipient creature ("put the same number and kind of counters" / "put one of +/// each of those kinds of counters"). The kind→count multiset is read from +/// `state.current_trigger_events` — which, under the per-recipient firing model +/// (`matching_counter_added_events_by_recipient`), holds exactly one recipient's +/// `GameEvent::CounterAdded` occurrences (one per kind placed on it). Unlike +/// `resolve_move` this reads the DELTA the event placed, not the recipient's +/// total counter map. The multiset is snapshotted from the firing's events +/// (CR 608.2h), so later changes to the recipient's counters don't affect it. +pub fn resolve_reproduce_event_counters( + state: &mut GameState, + ability: &ResolvedAbility, + events: &mut Vec, +) -> Result<(), EffectError> { + let per_kind_count = match &ability.effect { + Effect::ReproduceEventCounters { per_kind_count, .. } => *per_kind_count, + _ => return Ok(()), + }; + + // Fold the firing's `CounterAdded` occurrences into a kind→count multiset, + // preserving first-seen kind order for deterministic placement/event order. + let mut reproduced: Vec<(CounterType, u32)> = Vec::new(); + for event in &state.current_trigger_events { + let GameEvent::CounterAdded { + counter_type, + count, + .. + } = event + else { + continue; + }; + // CR 122.1: "one of each of those kinds" (PerKind) ignores the event's + // per-kind magnitude; "the same number and kind" (SameNumber) reproduces + // exactly what the event placed, summing repeated kinds. + let amount = match per_kind_count { + EventCounterReproductionCount::SameNumber => *count, + EventCounterReproductionCount::PerKind(n) => n, + }; + if amount == 0 { + continue; + } + match reproduced.iter_mut().find(|(kind, _)| kind == counter_type) { + Some((_, existing)) => match per_kind_count { + // SameNumber sums repeated kinds; PerKind is a flat per-kind + // count, so a repeated kind stays at `n` (already recorded). + EventCounterReproductionCount::SameNumber => *existing += amount, + EventCounterReproductionCount::PerKind(_) => {} + }, + None => reproduced.push((counter_type.clone(), amount)), + } + } + + if reproduced.is_empty() { + events.push(GameEvent::EffectResolved { + kind: EffectKind::from(&ability.effect), + source_id: ability.source_id, + subject: None, + }); + return Ok(()); + } + + let targets = resolve_defined_or_targets(state, ability); + let additions: Vec = targets + .into_iter() + .flat_map(|obj_id| { + reproduced.iter().map(move |(kind, amount)| { + object_counter_addition(ability.controller, obj_id, kind.clone(), *amount) + }) + }) + .collect(); + + let completion = + PendingEffectResolved::new(EffectKind::from(&ability.effect), ability.source_id); + for (index, addition) in additions.iter().cloned().enumerate() { + if !apply_object_counter_addition(state, addition, events) { + // CR 614: a replacement choice paused placement — stash the rest so + // the continuation drains them after the choice resolves. + stash_pending_counter_additions(state, additions[index + 1..].to_vec(), completion); + return Ok(()); + } + } + + events.push(GameEvent::EffectResolved { + kind: EffectKind::from(&ability.effect), + source_id: ability.source_id, + subject: None, + }); + + Ok(()) +} + /// CR 122.1: Place counters on all battlefield objects matching a filter (no targeting). pub fn resolve_add_all( state: &mut GameState, @@ -1747,6 +1843,10 @@ fn resolve_defined_or_targets( let target_spec = match &ability.effect { Effect::MultiplyCounter { target, .. } | Effect::RemoveCounter { target, .. } + // CR 122.1 + CR 603.2c: reproduction targets exactly like `PutCounter` — + // `SelfRef` short-circuits to the source (Captain Marvel), a real target + // falls through to the chosen-target return (Aragorn). + | Effect::ReproduceEventCounters { target, .. } | Effect::PutCounter { target, .. } => Some(target), _ => None, }; @@ -4449,6 +4549,7 @@ mod tests { object_id, counter_type: CounterType::Plus1Plus1, count: 2, + .. } if *object_id == dest_id ))); } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 946bc54d9c..7bced311c6 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -4173,6 +4173,9 @@ pub fn resolve_effect( } Effect::ChooseCard { .. } => choose_card::resolve(state, ability, events), Effect::PutCounter { .. } => counters::resolve_add(state, ability, events), + Effect::ReproduceEventCounters { .. } => { + counters::resolve_reproduce_event_counters(state, ability, events) + } Effect::PutCounterAll { .. } => counters::resolve_add_all(state, ability, events), Effect::MultiplyCounter { .. } => counters::resolve_multiply(state, ability, events), Effect::DoublePT { .. } => pump::resolve_double_pt(state, ability, events), @@ -5076,6 +5079,7 @@ fn affected_objects_from_events( Effect::PutCounter { .. } | Effect::PutCounterAll { .. } | Effect::MultiplyCounter { .. } + | Effect::ReproduceEventCounters { .. } | Effect::MoveCounters { .. } => events .iter() .filter_map(|event| match event { @@ -5253,6 +5257,7 @@ fn mandatory_parent_effect_performed(effect: &Effect, events: &[GameEvent]) -> b Effect::PutCounter { .. } | Effect::PutCounterAll { .. } | Effect::MultiplyCounter { .. } + | Effect::ReproduceEventCounters { .. } | Effect::MoveCounters { .. } => events .iter() .any(|event| matches!(event, GameEvent::CounterAdded { .. })), @@ -8974,6 +8979,7 @@ fn resolve_chain_body( state.push_optional_effect_frame(OptionalEffectFrame { ability: Box::new(ability_with_event_context_targets(state, ability)), trigger_event: state.current_trigger_event.clone(), + trigger_events: state.current_trigger_events.clone(), trigger_match_count: state.current_trigger_match_count, }); state.waiting_for = WaitingFor::OpponentMayChoice { @@ -9039,6 +9045,10 @@ fn resolve_chain_body( // optional ("may") trigger's effect resolves `TriggeringPlayer` and // other event-context refs exactly as a non-optional trigger would. trigger_event: state.current_trigger_event.clone(), + // CR 603.2c + CR 608.2: capture the PLURAL event batch in lockstep so + // a "you may" reproduction (Captain Marvel, Apex Avenger) folds every + // `CounterAdded` occurrence when the decision resumes. + trigger_events: state.current_trigger_events.clone(), // CR 603.2c + CR 608.2: mirror the batched-trigger subject count so a // "you may" sub-ability of a batched trigger (Ur-Dragon's optional // permanent-from-hand sub-effect) resumes with the same diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 69a8d6576d..800918da88 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -15551,6 +15551,7 @@ mod stage2_injector_tests { state.push_optional_effect_frame(crate::types::resolution::OptionalEffectFrame { ability: Box::new(optional), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); state.waiting_for = WaitingFor::OptionalEffectChoice { diff --git a/crates/engine/src/game/engine_payment_choices.rs b/crates/engine/src/game/engine_payment_choices.rs index 149ba172cd..44f74864f7 100644 --- a/crates/engine/src/game/engine_payment_choices.rs +++ b/crates/engine/src/game/engine_payment_choices.rs @@ -61,6 +61,7 @@ pub(super) fn handle_optional_effect_choice( let OptionalEffectFrame { ability, trigger_event: pending_event, + trigger_events: pending_events, trigger_match_count: pending_count, } = frame; let choice = if accept { @@ -74,6 +75,12 @@ pub(super) fn handle_optional_effect_choice( // `TriggeringPlayer` and other event-context refs resolve correctly. let previous_trigger_event = state.current_trigger_event.clone(); state.current_trigger_event = pending_event; + // CR 603.2c + CR 608.2: restore the PLURAL batched-trigger event list + // too — an effect that folds the whole event batch (e.g. + // `Effect::ReproduceEventCounters` reading every `CounterAdded` + // occurrence) must see all occurrences, not just the singular event. + let previous_trigger_events = std::mem::take(&mut state.current_trigger_events); + state.current_trigger_events = pending_events; // CR 603.2c + CR 608.2: mirror restoration of the batched-trigger // subject count so a `QuantityRef::EventContextAmount` resolved during // the resumed sub-ability reads the same "that many" the pre-pause @@ -83,6 +90,7 @@ pub(super) fn handle_optional_effect_choice( let result = effects::resolve_optional_effect_decision(state, *ability, choice, events, 1); state.current_trigger_event = previous_trigger_event; + state.current_trigger_events = previous_trigger_events; state.current_trigger_match_count = previous_trigger_match_count; result.map_err(|e| EngineError::InvalidAction(format!("{e:?}")))?; } else if state.pending_trigger.as_ref().is_some_and(|t| { @@ -2033,6 +2041,7 @@ mod tests { state.push_optional_effect_frame(OptionalEffectFrame { ability: Box::new(optional), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); state.waiting_for = WaitingFor::OptionalEffectChoice { @@ -2063,6 +2072,7 @@ mod tests { state.push_optional_effect_frame(OptionalEffectFrame { ability: Box::new(optional), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); state.waiting_for = WaitingFor::OptionalEffectChoice { @@ -2099,6 +2109,7 @@ mod tests { state.push_optional_effect_frame(OptionalEffectFrame { ability: Box::new(optional), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); state.waiting_for = WaitingFor::OptionalEffectChoice { @@ -2132,6 +2143,7 @@ mod tests { state.push_optional_effect_frame(OptionalEffectFrame { ability: Box::new(optional), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); state.waiting_for = WaitingFor::OptionalEffectChoice { @@ -2163,6 +2175,7 @@ mod tests { state.push_optional_effect_frame(OptionalEffectFrame { ability: Box::new(optional), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); state.waiting_for = WaitingFor::OptionalEffectChoice { @@ -2193,6 +2206,7 @@ mod tests { state.push_optional_effect_frame(OptionalEffectFrame { ability: Box::new(optional), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); state.waiting_for = WaitingFor::OptionalEffectChoice { @@ -2223,6 +2237,7 @@ mod tests { state.push_optional_effect_frame(OptionalEffectFrame { ability: Box::new(optional), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); state.waiting_for = WaitingFor::OptionalEffectChoice { diff --git a/crates/engine/src/game/engine_phase_trigger_regression_tests.rs b/crates/engine/src/game/engine_phase_trigger_regression_tests.rs index d3dcd65125..e314633bdb 100644 --- a/crates/engine/src/game/engine_phase_trigger_regression_tests.rs +++ b/crates/engine/src/game/engine_phase_trigger_regression_tests.rs @@ -1834,6 +1834,7 @@ fn optional_effect_choice_accept_preserves_nested_effect_zone_choice_continuatio state.push_optional_effect_frame(crate::types::OptionalEffectFrame { ability: Box::new(ability), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); state.waiting_for = WaitingFor::OptionalEffectChoice { @@ -1884,6 +1885,7 @@ fn opponent_may_choice_accept_preserves_nested_effect_zone_choice_continuation() state.push_optional_effect_frame(crate::types::OptionalEffectFrame { ability: Box::new(ability), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); state.waiting_for = WaitingFor::OpponentMayChoice { diff --git a/crates/engine/src/game/log.rs b/crates/engine/src/game/log.rs index d620028f50..5f59c5a53c 100644 --- a/crates/engine/src/game/log.rs +++ b/crates/engine/src/game/log.rs @@ -741,6 +741,11 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec { object_id, counter_type, count, + // CR 122.1: the log line names the counters and recipient; the placing + // player is implied by the entry's stack/ability context, consistent + // with every other counter-placement log line (actor deliberately + // not surfaced). + .. } => vec![ num(*count as i32), text(" "), diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index 378621c6b0..992f18f686 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -1248,6 +1248,8 @@ fn walk_effect(effect: &Effect, out: &mut Vec) { | Effect::DoublePT { .. } | Effect::DoublePTAll { .. } | Effect::MoveCounters { .. } + // CR 122.1: reproduces counters — carries no conjure card name. + | Effect::ReproduceEventCounters { .. } | Effect::Animate { .. } | Effect::RegisterBending { .. } | Effect::Cleanup { .. } diff --git a/crates/engine/src/game/resolution_prompt.rs b/crates/engine/src/game/resolution_prompt.rs index 4be3c26823..f81bc3fd68 100644 --- a/crates/engine/src/game/resolution_prompt.rs +++ b/crates/engine/src/game/resolution_prompt.rs @@ -351,6 +351,7 @@ fn effect_offers_choice(e: &Effect) -> bool { | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } + | Effect::ReproduceEventCounters { .. } | Effect::PutCounterAll { .. } | Effect::MultiplyCounter { .. } | Effect::DoublePT { .. } diff --git a/crates/engine/src/game/sba.rs b/crates/engine/src/game/sba.rs index 343b418da3..fdc761aaa5 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -4399,6 +4399,7 @@ mod tests { object_id: id, counter_type: CounterType::Lore, count: 1, + actor: PlayerId(0), }]; check_state_based_actions(&mut state, &mut events); diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs index 065bd044f0..bcbb0c471f 100644 --- a/crates/engine/src/game/targeting.rs +++ b/crates/engine/src/game/targeting.rs @@ -1691,6 +1691,9 @@ pub(crate) fn extract_player_from_event( // TriggeringPlayer` fell back to the ability controller, hitting the // wrong player (Suture Priest #560, Bloodchief Ascension #546). GameEvent::ZoneChanged { record, .. } => Some(record.controller), + // CR 122.1 + CR 603.7c: "that player" / `TriggeringPlayer` on a + // counter-placement trigger is the player who put the counters. + GameEvent::CounterAdded { actor, .. } => Some(*actor), _ => None, } } diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index b6c768b664..6457a5e0ed 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -805,6 +805,7 @@ fn keys_from_effect_kind(kind: EffectKind, push: &mut impl FnMut(TriggerEventKey | EffectKind::PutCounter | EffectKind::PutCounterAll | EffectKind::MultiplyCounter + | EffectKind::ReproduceEventCounters | EffectKind::DoublePT | EffectKind::DoublePTAll | EffectKind::MoveCounters diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index e851ad5aba..6f83a486aa 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -2146,11 +2146,19 @@ pub(super) fn match_counter_added( object_id, counter_type, count, + actor, } = event { if !valid_card_matches(trigger, state, *object_id, source_context) { return false; } + // CR 603.2c: "whenever you put …" / "whenever an opponent puts …" gates + // on the player who placed the counters. No-op when `valid_target` is + // `None` (the passive "counters are put on ~" form, which every existing + // counter-added card uses). + if !valid_player_matches(trigger, state, *actor, source_context) { + return false; + } // CR 714.2a: Apply counter filter (type + optional threshold crossing). if let Some(ref filter) = trigger.counter_filter { if filter.counter_type != *counter_type { @@ -11614,6 +11622,7 @@ mod tests { object_id: saga_id, counter_type: crate::types::counter::CounterType::Lore, count: 1, + actor: PlayerId(0), }; // Trigger for chapter 1 (threshold=1) should fire: 0 < 1 <= 1 @@ -11682,6 +11691,7 @@ mod tests { object_id: saga_id, counter_type: crate::types::counter::CounterType::Lore, count: 3, + actor: PlayerId(0), }; assert!( match_counter_added( @@ -11729,6 +11739,7 @@ mod tests { object_id: normal_id, counter_type: crate::types::counter::CounterType::Lore, count: 3, + actor: PlayerId(0), }; assert!( match_counter_added( @@ -11754,6 +11765,7 @@ mod tests { object_id: saga_id, counter_type: crate::types::counter::CounterType::Plus1Plus1, count: 2, + actor: PlayerId(0), }; let p1p1_trigger = TriggerDefinition::new(TriggerMode::CounterAdded) .valid_card(TargetFilter::SelfRef) @@ -11792,6 +11804,7 @@ mod tests { object_id: saga_id, counter_type: crate::types::counter::CounterType::Lore, count: 2, // Added 2 at once + actor: PlayerId(0), }; // Both chapter 1 (threshold=1) and chapter 2 (threshold=2) should fire @@ -11862,6 +11875,7 @@ mod tests { object_id: saga_id, counter_type: crate::types::counter::CounterType::Plus1Plus1, count: 1, + actor: PlayerId(0), }; let trigger = TriggerDefinition::new(TriggerMode::CounterAdded) @@ -11902,6 +11916,7 @@ mod tests { object_id: saga_id, counter_type: crate::types::counter::CounterType::Lore, count: 1, + actor: PlayerId(0), }; // Filter with no threshold fires on any addition of the matching type diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index e5a2a52461..2850e277a9 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -1006,6 +1006,45 @@ pub fn resolve_and_apply_trigger_collection( Ok(()) } +/// CR 603.2g + CR 603.2 + CR 603.4: The batched-trigger candidate survival test — +/// the single authority for "does this simultaneous-event candidate become a +/// firing candidate for `trig_def`". Three checks, in order: +/// 1. CR 603.2g: the candidate is not suppressed by an active +/// replacement-style suppress-trigger static. +/// 2. CR 603.2: the candidate matches this trigger's event-shape matcher. +/// 3. CR 603.4: the candidate satisfies the intervening-if condition, evaluated +/// against THIS specific candidate event so a per-candidate "it" binds to it. +/// +/// Shared by `matching_batched_trigger_events` (all-in-one batch) and +/// `matching_counter_added_events_by_recipient` (per-recipient grouping) so the +/// two apply an identical filter chain; they differ only in how survivors are +/// grouped and whether `contextual_batched_trigger_event` is layered on top. +fn candidate_passes_batched_filters( + state: &GameState, + candidate: &GameEvent, + trig_def: &TriggerDefinition, + source_context: &TriggerSourceContext, + controller: PlayerId, + matcher: TriggerMatcher, + active_suppress_triggers: &[ActiveSuppressTriggerStatic], +) -> bool { + if event_is_suppressed_by_static_triggers_cached(state, candidate, active_suppress_triggers) { + return false; + } + if !matcher(candidate, trig_def, source_context, state) { + return false; + } + trig_def.condition.as_ref().is_none_or(|condition| { + check_trigger_condition_with_source( + state, + condition, + controller, + Some(source_context), + Some(candidate), + ) + }) +} + fn matching_batched_trigger_events( state: &GameState, event_batch: &[GameEvent], @@ -1018,30 +1057,95 @@ fn matching_batched_trigger_events( event_batch .iter() .filter(|candidate| { - !event_is_suppressed_by_static_triggers_cached( + candidate_passes_batched_filters( state, candidate, + trig_def, + source_context, + controller, + matcher, active_suppress_triggers, ) }) - .filter(|candidate| matcher(candidate, trig_def, source_context, state)) - .filter(|candidate| { - trig_def.condition.as_ref().is_none_or(|condition| { - check_trigger_condition_with_source( - state, - condition, - controller, - Some(source_context), - Some(candidate), - ) - }) - }) .filter_map(|candidate| { contextual_batched_trigger_event(state, candidate, trig_def, source_context) }) .collect() } +/// CR 603.2c: "Whenever you put one or more counters on a creature" triggers +/// once per recipient creature — but a single counter-placement event may place +/// several kinds of counters (one `GameEvent::CounterAdded` per kind) on several +/// creatures at once (proliferate). Group each recipient's kind-events into one +/// firing so the intervening-"if it" binds to that single recipient and the +/// per-recipient multiset fold (`Effect::ReproduceEventCounters`) reproduces +/// exactly what was placed on it. Applies the identical filter chain as +/// `matching_batched_trigger_events` (static suppression → matcher → +/// per-candidate intervening-if), then groups the survivors by `object_id` +/// preserving first-seen order. No empty inner batches are produced. +/// +/// The per-candidate survival test is the shared `candidate_passes_batched_filters` +/// (identical to `matching_batched_trigger_events`); this function layers only the +/// two genuine deltas on top — grouping survivors by `object_id` and the +/// deliberate omission of `contextual_batched_trigger_event` (a no-op passthrough +/// for `CounterAdded` events anyway, since it only narrows attack-family events). +fn matching_counter_added_events_by_recipient( + state: &GameState, + event_batch: &[GameEvent], + trig_def: &TriggerDefinition, + source_context: &TriggerSourceContext, + controller: PlayerId, + matcher: TriggerMatcher, + active_suppress_triggers: &[ActiveSuppressTriggerStatic], +) -> Vec> { + let mut groups: Vec<(ObjectId, Vec)> = Vec::new(); + for candidate in event_batch { + let GameEvent::CounterAdded { object_id, .. } = candidate else { + continue; + }; + if !candidate_passes_batched_filters( + state, + candidate, + trig_def, + source_context, + controller, + matcher, + active_suppress_triggers, + ) { + continue; + } + match groups.iter_mut().find(|(id, _)| id == object_id) { + Some((_, events)) => events.push(candidate.clone()), + None => groups.push((*object_id, vec![candidate.clone()])), + } + } + groups.into_iter().map(|(_, events)| events).collect() +} + +/// CR 603.2c: Whether a `CounterAdded` trigger fires once PER RECIPIENT object +/// rather than once for the whole simultaneous batch. +/// +/// This is a property of the TRIGGER PHRASING, not of the effect. The only +/// phrasing that marks a `CounterAdded` trigger `batched` is the kind-agnostic +/// "one or more counters on a " form (set by +/// `try_parse_counter_trigger`, re-gated off for still-`Unimplemented` effects by +/// `lower_trigger_ir`). Under CR 603.2c a single event that places counters on +/// several recipients "contains multiple occurrences", so the ability triggers +/// once for EACH recipient — while a multi-KIND placement on ONE recipient folds +/// into a single occurrence. `matching_counter_added_events_by_recipient` +/// realizes exactly that granularity (group by `object_id`), so the per-recipient +/// intervening-if ("if it's not a Kree") binds "it" to a single recipient and any +/// effect — reproduction, draw, damage — resolves once per recipient. +/// +/// Gating this on the trigger structure keeps the firing-granularity decision +/// class-level. Keying it on a specific `Effect` leaf (e.g. only +/// `ReproduceEventCounters`) would silently fall same-phrasing cards with a +/// non-reproduction effect through to the all-in-one arm, collapsing their +/// per-recipient occurrences into a single wrong firing (CR 603.2c violation). +fn counter_added_fires_per_recipient(trig_def: &TriggerDefinition) -> bool { + trig_def.batched && matches!(trig_def.mode, TriggerMode::CounterAdded) +} + /// CR 508.1 + CR 603.2: Split an attack declaration into the singleton event /// contexts required by an event-referential attacker demonstrative. A plural /// declaration has no single object for "that Wolf" to bind. @@ -2086,7 +2190,28 @@ fn collect_matching_triggers_inner( .as_ref() .map(|exec| (exec.modal.clone(), exec.mode_abilities.clone())) .unwrap_or_default(); - let trigger_event_batches = if trig_def.batched { + let trigger_event_batches = if counter_added_fires_per_recipient(trig_def) { + // CR 603.2c: the "one or more counters on a " + // class fires once per recipient object, folding that recipient's + // whole kind-multiset into one firing. This is a class-level + // property of the trigger phrasing (see + // `counter_added_fires_per_recipient`), NOT of the effect leaf, so + // every batched `CounterAdded` trigger — reproduction, draw, or + // damage — routes here and binds its per-recipient "it" correctly. + let batches = matching_counter_added_events_by_recipient( + state, + event_batch, + trig_def, + &source_context, + controller, + matcher, + active_suppress_triggers, + ); + if batches.is_empty() { + continue; + } + batches + } else if trig_def.batched { let trigger_events = matching_batched_trigger_events( state, event_batch, @@ -20988,11 +21113,13 @@ pub mod tests { object_id: countered, counter_type: CounterType::Lore, count: 1, + actor: PlayerId(0), }, GameEvent::CounterAdded { object_id: countered, counter_type: CounterType::Plus1Plus1, count: 1, + actor: PlayerId(0), }, ]; diff --git a/crates/engine/src/parser/oracle_effect/counter.rs b/crates/engine/src/parser/oracle_effect/counter.rs index 1cfa5ffdbe..7305acea89 100644 --- a/crates/engine/src/parser/oracle_effect/counter.rs +++ b/crates/engine/src/parser/oracle_effect/counter.rs @@ -8,8 +8,8 @@ use nom::Parser; use crate::types::ability::{ ChosenCounterCountCondition, Comparator, CounterMoveSelection, CounterTransferMode, - DoublePTMode, DoubleTarget, Effect, MultiTargetSpec, ObjectScope, QuantityExpr, QuantityRef, - TargetFilter, + DoublePTMode, DoubleTarget, Effect, EventCounterReproductionCount, MultiTargetSpec, + ObjectScope, QuantityExpr, QuantityRef, TargetFilter, }; use crate::types::counter::{parse_counter_type, CounterType}; use crate::types::mana::ManaColor; @@ -634,6 +634,48 @@ pub(super) fn try_parse_put_counter<'a>( )) } +/// CR 122.1 + CR 603.2c: Parse the counter-reproduction effect body — "put the +/// same number and kind of counters on " (Captain Marvel, Apex Avenger; +/// Bold Plagiarist) or "put one of each of those kinds of counters on " +/// (Aragorn, Company Leader). Each axis (per-kind magnitude, target) is its own +/// combinator; no verbatim full-line match. Reuses `resolve_counter_placement_target` +/// so `~`→`SelfRef` and "up to one other target creature" are handled exactly as +/// for `PutCounter`. +pub(super) fn try_parse_reproduce_event_counters<'a>( + lower: &str, + text: &'a str, + ctx: &mut ParseContext, +) -> Option<(Effect, &'a str, Option)> { + fn reproduce_counter_phrase(input: &str) -> OracleResult<'_, EventCounterReproductionCount> { + let (rest, _) = tag("put ").parse(input)?; + let (rest, per_kind) = alt(( + value( + EventCounterReproductionCount::SameNumber, + tag("the same number and kind of counters"), + ), + value( + EventCounterReproductionCount::PerKind(1), + tag("one of each of those kinds of counters"), + ), + )) + .parse(rest)?; + let (rest, _) = tag(" on ").parse(rest)?; + Ok((rest, per_kind)) + } + + let (on_rest, per_kind_count) = reproduce_counter_phrase(lower).ok()?; + let (target, remainder, multi_target) = + resolve_counter_placement_target(on_rest, lower, text, ctx); + Some(( + Effect::ReproduceEventCounters { + target, + per_kind_count, + }, + remainder, + multi_target, + )) +} + fn parse_counter_for_each_suffix(remainder: &str) -> Option<(QuantityExpr, &str)> { // Delegate to the shared anchored "attach trailing for-each multiplier" // authority (CR 107.1 integer count templating) in oracle_effect::lower. diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index ea2f7c86be..ebd72d8653 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -12930,6 +12930,25 @@ pub(super) fn parse_zone_counter_ast( if tag::<_, _, OracleError<'_>>("put ").parse(lower).is_ok() && nom_primitives::scan_contains(lower, "counter") { + // CR 122.1 + CR 603.2c: "put the same number and kind of counters" / "put + // one of each of those kinds of counters" — reproduce the triggering + // event's counters (Captain Marvel, Apex Avenger). Detected before the + // generic counter-type paths so the "same number and kind"/"those kinds" + // phrasing is never mis-read as a literal counter name. + if let Some(( + Effect::ReproduceEventCounters { + target, + per_kind_count, + }, + _rem, + _multi_target, + )) = super::counter::try_parse_reproduce_event_counters(lower, text, ctx) + { + return Some(ZoneCounterImperativeAst::ReproduceEventCounters { + target, + per_kind_count, + }); + } // CR 122.1 + CR 122.6: "put [a[n]] [additional] counter of that kind on // " — add one counter of the kind chosen by a preceding // `ChooseCounterKind` (The Caves of Androzani). Detected before the @@ -13286,6 +13305,13 @@ pub(super) fn lower_zone_counter_ast(ast: ZoneCounterImperativeAst) -> Effect { selection, target, }, + ZoneCounterImperativeAst::ReproduceEventCounters { + target, + per_kind_count, + } => Effect::ReproduceEventCounters { + target, + per_kind_count, + }, } } diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index cc925993fe..db235f319b 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -15451,10 +15451,19 @@ fn lower_imperative_clause(text: &str, ctx: &mut ParseContext) -> ParsedEffectCl if clause.duration.is_none() { clause.duration = duration; } - // CR 115.1d: Post-parse fixup for PutCounter "up to N" multi_target. - // The multi_target is lost in the AST→Effect lowering chain, so we re-extract it - // from the original text when the effect is PutCounter with a targeted filter. - if matches!(clause.effect, Effect::PutCounter { .. }) && clause.multi_target.is_none() { + // CR 115.1d: Post-parse fixup for the "…counter(s) on up to N target …" shape. + // The multi_target is lost in the AST→Effect lowering chain, so we re-extract + // it from the original text. `PutCounter` and `ReproduceEventCounters` share + // the identical target-side placement grammar (Aragorn, Company Leader: "put + // one of each of those kinds of counters on up to one other target creature"), + // so both recover their optional cardinality through the same dedicated + // extractor. Without the reproduction arm the "up to one" bound is dropped and + // Aragorn's target binds as mandatory (min=1) instead of optional (min=0). + if matches!( + clause.effect, + Effect::PutCounter { .. } | Effect::ReproduceEventCounters { .. } + ) && clause.multi_target.is_none() + { clause.multi_target = extract_put_counter_multi_target(text); } // CR 601.2c: Post-parse fixup for exact-count multi-target text. The @@ -16223,6 +16232,27 @@ fn try_parse_verb_and_target<'a>( if tag::<_, _, OracleError<'_>>("put ").parse(lower).is_ok() && scan_contains_phrase(lower, "counter") { + // CR 122.1 + CR 603.2c: reproduce the triggering event's counters ("put + // the same number and kind of counters" / "put one of each of those + // kinds of counters"). Detected before the generic put-counter path so + // the reproduction phrasing is not mis-read as a literal counter name. + if let Some((effect @ Effect::ReproduceEventCounters { .. }, rem, _multi_target)) = + counter::try_parse_reproduce_event_counters(lower, text, ctx) + { + return Some(( + TargetedImperativeAst::ZoneCounterProxy(Box::new(match effect { + Effect::ReproduceEventCounters { + target, + per_kind_count, + } => ZoneCounterImperativeAst::ReproduceEventCounters { + target, + per_kind_count, + }, + _ => unreachable!("guarded by the match arm above"), + })), + rem, + )); + } if let Some(( Effect::PutCounter { counter_type, @@ -16714,13 +16744,31 @@ fn try_split_targeted_compound(text: &str, ctx: &mut ParseContext) -> Option") reaches this splitter and must recover its bound here too — the + // direct-clause fixup in `lower_imperative_clause` never runs for the compound + // return. Re-derive from the same building block that produced the effect. + let primary_multi_target = match &primary_effect { + Effect::PutCounter { .. } => { + let primary_clause = &text[..text.len() - remainder.len()]; + let primary_lower = primary_clause.to_ascii_lowercase(); + counter::try_parse_put_counter(&primary_lower, primary_clause, &mut ctx.clone()) + .and_then(|(_, _, multi)| multi) + } + Effect::ReproduceEventCounters { .. } => { + let primary_clause = &text[..text.len() - remainder.len()]; + let primary_lower = primary_clause.to_ascii_lowercase(); + counter::try_parse_reproduce_event_counters( + &primary_lower, + primary_clause, + &mut ctx.clone(), + ) .and_then(|(_, _, multi)| multi) - } else { - None + } + _ => None, }; Some(ParsedEffectClause { diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 090aa21fb7..c77d6a7c0c 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -6216,6 +6216,7 @@ pub(super) fn clause_is_dig_lookback_transparent(effect: &Effect) -> bool { | Effect::PutCounter { .. } | Effect::PutCounterAll { .. } | Effect::MultiplyCounter { .. } + | Effect::ReproduceEventCounters { .. } | Effect::DoublePT { .. } | Effect::DoublePTAll { .. } | Effect::MoveCounters { .. } diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index 5bc933e909..49cb482dcf 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -1724,6 +1724,13 @@ pub(crate) enum ZoneCounterImperativeAst { selection: crate::types::ability::CounterMoveSelection, target: TargetFilter, }, + /// CR 122.1 + CR 603.2c: "put the same number and kind of counters" / "put + /// one of each of those kinds of counters" — reproduce the triggering + /// event's counters onto `target`. Lowered to `Effect::ReproduceEventCounters`. + ReproduceEventCounters { + target: TargetFilter, + per_kind_count: crate::types::ability::EventCounterReproductionCount, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] diff --git a/crates/engine/src/parser/oracle_ir/doc.rs b/crates/engine/src/parser/oracle_ir/doc.rs index c921afeacb..aceae39b76 100644 --- a/crates/engine/src/parser/oracle_ir/doc.rs +++ b/crates/engine/src/parser/oracle_ir/doc.rs @@ -1507,6 +1507,8 @@ fn stamp_effect_printed_slot(effect: &mut Effect, slot: usize, kind: PrintedItem Effect::DoublePT { .. } => {} Effect::DoublePTAll { .. } => {} Effect::MoveCounters { .. } => {} + // CR 122.1: leaf counter effect — no printed-slot self-reference. + Effect::ReproduceEventCounters { .. } => {} Effect::Animate { .. } => {} Effect::RegisterBending { .. } => {} Effect::Cleanup { .. } => {} diff --git a/crates/engine/src/parser/oracle_static/mod.rs b/crates/engine/src/parser/oracle_static/mod.rs index a7572a7e40..4e51554274 100644 --- a/crates/engine/src/parser/oracle_static/mod.rs +++ b/crates/engine/src/parser/oracle_static/mod.rs @@ -155,6 +155,7 @@ pub(crate) use evasion::{ pub(crate) use grammar::map_keyword; pub(crate) use grammar::parse_pt_mod; pub(crate) use grammar::promote_nested_ability_quotes; +pub(crate) use grammar::typed_filter_for_subtype; pub(crate) use keyword_grant::{ classify_quoted_inner, parse_chosen_qualifier_subject, parse_continuous_modifications, parse_graveyard_granted_keyword_kind, parse_quoted_ability_modifications, split_keyword_list, diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index b8f6a3ecf9..e5c1231065 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -34,10 +34,10 @@ use super::oracle_nom::primitives::{ self as nom_primitives, scan_contains, scan_preceded, scan_split_at_phrase, }; use super::oracle_nom::target::parse_type_phrase as parse_type_phrase_nom; -use super::oracle_static::parse_commander_subject_filter_prefix; +use super::oracle_static::{parse_commander_subject_filter_prefix, typed_filter_for_subtype}; use super::oracle_target::{ attachment_kinds_filter_prop, parse_attachment_kind_disjunction, parse_type_phrase, - starts_with_type_list_continuation, starts_with_type_word, + parse_type_phrase_with_ctx, starts_with_type_list_continuation, starts_with_type_word, }; use super::oracle_util::{ canonicalize_subtype_name, is_core_type_name, is_non_subtype_subject_name, merge_or_filters, @@ -1762,17 +1762,24 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { // CR 603.2c: `try_parse_counter_trigger` marks the kind-agnostic "one or // more counters" phrasing as batched-by-phrasing (`def.batched = true`) but // cannot see the lowered effect. Re-gate here now that `execute` is known: - // batch only when the reproduction is NOT an unimplemented per-kind effect. - // The Tier-3 "same number and kind"/"those kinds" reproduction cards (Bold - // Plagiarist / Aragorn, Company Leader / Captain Marvel, Apex Avenger) - // currently lower to `Effect::Unimplemented`; batching them would route - // their read into the scalar counter-magnitude arm of - // `count_matching_trigger_event_subjects` and mis-resolve. Gate on the - // `Effect::Unimplemented` VARIANT, never its name/description string (a - // verbatim-text match is prohibited). - // ponytail: BB-FU11 (task #23) — when Tier-3 per-kind counter reproduction - // becomes a typed effect, re-gate this predicate on THAT effect; otherwise - // those cards auto-batch into the magnitude arm and mis-resolve. + // still-`Unimplemented` forms are forced non-batched (a not-yet-typed effect + // that must not consume a multi-event batch). + // + // Any non-`Unimplemented` effect keeps `def.batched = true`. At runtime, a + // batched `CounterAdded` trigger fires once per recipient object + // (`counter_added_fires_per_recipient` → `matching_counter_added_events_by_ + // recipient` in game/triggers.rs) — a class-level CR 603.2c property of the + // "one or more counters on a " phrasing, NOT of the + // effect leaf. That granularity binds a per-recipient intervening-if ("if + // it's not a Kree") to a single recipient and resolves the effect once per + // recipient, whether it is the reproduction class ("same number and kind" / + // "one of each of those kinds" — Captain Marvel, Apex Avenger; Bold + // Plagiarist; Aragorn, Company Leader → `Effect::ReproduceEventCounters`) or + // a non-reproduction effect ("draw a card", "deals that much damage"). The + // reproduction resolver reads `state.current_trigger_events` (that + // recipient's whole multiset) directly; magnitude effects read the same + // per-recipient batch, so a single-recipient multi-kind placement still + // aggregates within one firing. if def.mode == TriggerMode::CounterAdded && def.batched { def.batched = execute.as_deref().is_some_and(|ability| { !matches!(ability.effect.as_ref(), Effect::Unimplemented { .. }) @@ -5566,6 +5573,39 @@ fn extract_if_condition_with_card_name( } } + // CR 603.4 + CR 205.3: "if it's [not] a " on the triggering event's + // subject (Captain Marvel: "if it's not a Kree"). Registered BEFORE the + // zone-change filter path so recognized subtypes route to + // `EventObjectMatchesFilter`; because it consumes only recognized subtypes + // ("token" is not one), "if it's not a token" still falls through to the + // zone-change token condition below (finding 5). + // + // An intervening-if is defined by POSITION: it sits at the head of the effect + // text ("Whenever X, if it's a , "), gating whether the + // ability triggers at all (checked at trigger-time AND on resolution). It must + // NOT be conflated with a TRAILING resolution conditional + // (" ... if it's a card" — Oathkeeper, Takeno's Daisho: + // "return that card ... if it's a Samurai card"), which only gates the effect + // at resolution and leaves the trigger firing unconditionally. `scan_preceded` + // would otherwise match that trailing clause and wrongly promote it to a + // trigger condition, changing whether the ability goes on the stack. Restrict + // to the leading position (`before` empty, modulo whitespace) so trailing + // conditionals fall through to the effect-level ChangeZone gate as before. + if let Some((before, (filter, negated), rest)) = + scan_preceded(&lower, parse_event_object_subtype_intervening_if) + .filter(|(before, _, _)| before.trim_start().is_empty()) + { + let pos = before.len(); + let clause_len = lower.len() - before.len() - rest.len(); + // CR 603.4 + CR 603.10: route zone-change triggers (dies/leaves) through + // the event-snapshot evaluator; non-zone events (CounterAdded) stay live. + let condition = build_event_object_subtype_condition(filter, negated, trigger_zone_change); + return ( + strip_condition_clause(text, pos, clause_len), + Some(condition), + ); + } + if let Some(result) = try_extract_zone_change_object_filter_condition( &lower, text, @@ -5787,6 +5827,79 @@ fn parse_event_damage_source_chain(phrase: &str) -> TargetFilter { first } +/// CR 603.4 + CR 205.3: parse "if it's [not] a " and return the typed +/// subject filter plus whether it is negated. The CALLER +/// (`build_event_object_subtype_condition`) chooses the evaluator authority, +/// because the correct "it" resolution depends on the TRIGGER KIND, not on this +/// phrase: a zone-change trigger judges the event snapshot, a non-zone event +/// judges the live event object. +/// +/// The core type is derived from the subtype itself via the shared +/// `typed_filter_for_subtype` authority (CR 205.3 subtype→card-type pools), NOT +/// hardcoded to creature. This recognizer is registered on the GENERAL +/// intervening-if path (`extract_if_condition_with_card_name`), so the subject +/// "it" can be any permanent — e.g. "if it's not an Equipment" (artifact), +/// "if it's not an Aura" (enchantment). A hardcoded `creature()` lock would make +/// the inner filter unsatisfiable for a non-creature subtype (an Equipment is +/// never a creature), and a negated clause `Not()` inverts to always +/// true — firing FOR the very subtype it was meant to exclude. +/// +/// Finding-5 precedence guard: only a recognized subtype is consumed +/// (`parse_subtype`, which rejects "token"). "if it's not a token" therefore +/// declines here and falls through to +/// `parse_zone_change_object_token_contraction_intervening_if` (CR 111.1), +/// keeping the zone-change token condition intact. +fn parse_event_object_subtype_intervening_if( + input: &str, +) -> OracleResult<'_, (TargetFilter, bool)> { + let (rest, _) = tag("if it").parse(input)?; + // Longest-match: the negated forms ("'s not"/" is not"/" isn't") share a + // prefix with the plain forms ("'s"/" is"), so they must be tried first. + let (rest, negated) = alt(( + value(true, alt((tag("'s not "), tag(" is not "), tag(" isn't ")))), + value(false, alt((tag("'s "), tag(" is ")))), + )) + .parse(rest)?; + let (rest, _) = alt((tag("an "), tag("a "))).parse(rest)?; + let (subtype, consumed) = parse_subtype(rest).ok_or_else(|| oracle_err(rest))?; + let rest = &rest[consumed..]; + let filter = TargetFilter::Typed(typed_filter_for_subtype(&subtype)); + Ok((rest, (filter, negated))) +} + +/// CR 603.4 + CR 603.10: Build the trigger condition for a recognized "if it's +/// [not] a " intervening-if, choosing the evaluator authority by trigger +/// kind. For a ZONE-CHANGE trigger (Otherworldly Escort: "when this creature +/// dies, if it's not a Spirit") the subject "it" is the object AS IT EXISTED in +/// the zone-change event, so it must be judged from the event snapshot via +/// `ZoneChangeObjectMatchesFilter` (backed by `matches_zone_change_event_object_ +/// filter`). The live-first `EventObjectMatchesFilter` matcher would otherwise +/// judge a same-ID incarnation that re-entered before the recheck, not the object +/// that left. For a non-zone event (Captain Marvel's `CounterAdded` "if it's not +/// a Kree") there is no zone snapshot and the event object is resolved live via +/// `EventObjectMatchesFilter`. +fn build_event_object_subtype_condition( + filter: TargetFilter, + negated: bool, + trigger_zone_change: Option<(Zone, Zone)>, +) -> TriggerCondition { + let condition = match trigger_zone_change { + Some((origin, destination)) => TriggerCondition::ZoneChangeObjectMatchesFilter { + origin: Some(origin), + destination, + filter, + }, + None => TriggerCondition::EventObjectMatchesFilter { filter }, + }; + if negated { + TriggerCondition::Not { + condition: Box::new(condition), + } + } else { + condition + } +} + /// CR 603.4 + CR 111.1: Token intervening-if with `'s not` contraction /// ("if it's not a token"). The legacy `if it ` + `isn't`/`is not` path /// already covers explicit negation; only the apostrophe contraction needs @@ -9617,14 +9730,19 @@ fn parse_single_subject<'a>(text: &'a str, ctx: &mut ParseContext) -> (TargetFil return (filter, rest); } - // "a "/"an " + type phrase (general subject) + // "a "/"an " + type phrase (general subject). Thread `ctx` so a controller + // anaphor inside the type phrase ("a creature they control") binds to the + // caller's `relative_player_scope` — e.g. the counter-placement actor for + // Bold Plagiarist ("an opponent puts … on a creature they control"). With a + // default ctx (`relative_player_scope == None`) this is identical to the + // scope-free `parse_type_phrase`, so every other subject is unchanged. if let Ok((after, ())) = alt(( value((), tag::<_, _, OracleError<'_>>("a ")), value((), tag("an ")), )) .parse(text) { - let (filter, rest) = parse_type_phrase(after); + let (filter, rest) = parse_type_phrase_with_ctx(after, ctx); return (filter, rest); } @@ -16206,20 +16324,89 @@ fn try_parse_counter_trigger(lower: &str) -> Option<(TriggerMode, TriggerDefinit def.batched = true; } - // Parse the subject after "on " + // CR 603.2c: "Whenever you put …" / "Whenever an opponent puts …" gates the + // trigger on the player who placed the counters. The passive "counters are + // put on ~" form matches no actor and leaves `valid_target` unset (fires + // regardless of actor, as every existing counter-added card does). + let actor_filter = parse_counter_actor_prefix(counter_prefix); + if let Some(actor_filter) = actor_filter.clone() { + def.valid_target = Some(actor_filter); + } + + // Parse the subject after "on ". CR 608.2c + CR 122.1: a controller anaphor + // in the subject ("on a creature they control", Bold Plagiarist) refers to + // the player who placed the counters — the actor gate above, not the + // trigger source's controller. Seed `relative_player_scope` from the actor + // so the subject parser's "they control" arm binds to that player + // (`Opponent` for "an opponent puts …", `You` for "you put …") instead of + // silently defaulting to `You` and firing on the wrong creatures. if tag::<_, _, OracleError<'_>>("~") .parse(subject_text) .is_ok() { def.valid_card = Some(TargetFilter::SelfRef); } else { - let (filter, _) = parse_single_subject(subject_text, &mut ParseContext::default()); + let mut subject_ctx = ParseContext { + relative_player_scope: counter_actor_anaphor_scope(actor_filter.as_ref()), + ..ParseContext::default() + }; + let (filter, _) = parse_single_subject(subject_text, &mut subject_ctx); def.valid_card = Some(filter); } Some((TriggerMode::CounterAdded, def)) } +/// CR 603.2c: Extract the actor gate from a counter-placement trigger's prefix +/// (the text before "counter"). "you put"/"you've put" → `Controller`; +/// "an opponent puts"/"an opponent has put" → `Opponent`. The passive +/// "counters are put …" and subjectless forms match nothing (`None`), leaving +/// the trigger un-gated on actor. Composed from `alt`/`value` combinators over +/// the already-lowercased prefix — each verb tense is one leaf of an `alt`. +fn parse_counter_actor_prefix(prefix: &str) -> Option { + let (rest, _) = opt(alt(( + tag::<_, _, OracleError<'_>>("whenever "), + tag("when "), + ))) + .parse(prefix.trim_start()) + .ok()?; + alt(( + value( + TargetFilter::Controller, + alt(( + tag::<_, _, OracleError<'_>>("you've put "), + tag("you have put "), + tag("you put "), + )), + ), + value( + TargetFilter::Opponent, + alt(( + tag("an opponent puts "), + tag("an opponent has put "), + tag("an opponent put "), + )), + ), + )) + .parse(rest) + .ok() + .map(|(_, filter)| filter) +} + +/// CR 608.2c: Map a counter-placement actor gate to the `ControllerRef` its +/// "they/their control" anaphor resolves to in the recipient subject. "you put … +/// on a creature they control" → `You`; "an opponent puts … on a creature they +/// control" (Bold Plagiarist) → `Opponent`. Returns `None` for the passive / +/// un-gated form (no actor), leaving the subject parser's own `You` fallback in +/// place. This is the actor→controller bridge for `relative_player_scope`. +fn counter_actor_anaphor_scope(actor: Option<&TargetFilter>) -> Option { + match actor? { + TargetFilter::Controller => Some(ControllerRef::You), + TargetFilter::Opponent => Some(ControllerRef::Opponent), + _ => None, + } +} + /// "When the twelfth hour counter is put on ~" — thresholded counter triggers. /// Uses the same `CounterTriggerFilter` building block as Saga chapters, so the /// runtime fires only when the object crosses the named counter threshold. diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 621ec80729..0f8025ba82 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -9146,6 +9146,162 @@ fn trigger_intervening_if_discarded_card_has_madness() { ); } +/// CR 603.4 + CR 205.3: "if it's [not] a " derives its core type from +/// the subtype, NOT a hardcoded creature lock. Registered on the GENERAL +/// intervening-if path, the recognizer intercepts non-creature subjects too, so a +/// non-creature subtype ("Equipment" → artifact, "Aura" → enchantment) must build +/// a filter with the MATCHING core type. A creature lock would make the inner +/// filter unsatisfiable (an Equipment is never a creature) and a negated clause +/// `Not()` would invert to always-true — firing FOR the very subtype +/// it was meant to exclude. Both the negated and plain forms are checked. +#[test] +fn trigger_intervening_if_noncreature_subtype_derives_core_type() { + // Negated artifact subtype: filter must carry Artifact (not Creature) so the + // Not-gate is satisfiable. + let (_, condition) = extract_if_condition("if it's not an Equipment, draw a card"); + let Some(TriggerCondition::Not { condition }) = condition else { + panic!("expected negated intervening-if, got {condition:?}"); + }; + let TriggerCondition::EventObjectMatchesFilter { + filter: TargetFilter::Typed(tf), + } = condition.as_ref() + else { + panic!("expected EventObjectMatchesFilter, got {condition:?}"); + }; + assert!( + tf.type_filters.contains(&TypeFilter::Artifact), + "Equipment must derive the Artifact core type, not a creature lock: {:?}", + tf.type_filters + ); + assert!( + !tf.type_filters.contains(&TypeFilter::Creature), + "Equipment filter must not be locked to Creature: {:?}", + tf.type_filters + ); + assert!( + tf.type_filters + .contains(&TypeFilter::Subtype("Equipment".to_string())), + "Equipment subtype must be preserved: {:?}", + tf.type_filters + ); + + // Plain (non-negated) enchantment subtype: same core-type derivation. + let (_, condition) = extract_if_condition("if it's an Aura, draw a card"); + let Some(TriggerCondition::EventObjectMatchesFilter { + filter: TargetFilter::Typed(tf), + }) = condition + else { + panic!("expected EventObjectMatchesFilter, got {condition:?}"); + }; + assert!( + tf.type_filters.contains(&TypeFilter::Enchantment), + "Aura must derive the Enchantment core type: {:?}", + tf.type_filters + ); + assert!( + !tf.type_filters.contains(&TypeFilter::Creature), + "Aura filter must not be locked to Creature: {:?}", + tf.type_filters + ); +} + +/// CR 603.4 + CR 205.3: A genuine creature subtype ("Kree", Captain Marvel) still +/// derives the Creature core type — the core-type derivation must not regress the +/// creature-recipient case that motivated the recognizer. +#[test] +fn trigger_intervening_if_creature_subtype_still_creature() { + let (_, condition) = extract_if_condition("if it's not a Kree, draw a card"); + let Some(TriggerCondition::Not { condition }) = condition else { + panic!("expected negated intervening-if, got {condition:?}"); + }; + let TriggerCondition::EventObjectMatchesFilter { + filter: TargetFilter::Typed(tf), + } = condition.as_ref() + else { + panic!("expected EventObjectMatchesFilter, got {condition:?}"); + }; + assert!( + tf.type_filters.contains(&TypeFilter::Creature), + "Kree must derive the Creature core type: {:?}", + tf.type_filters + ); + assert!( + tf.type_filters + .contains(&TypeFilter::Subtype("Kree".to_string())), + "Kree subtype must be preserved: {:?}", + tf.type_filters + ); +} + +/// CR 603.4: A TRAILING subtype conditional (" ... if it's a +/// card" — Oathkeeper, Takeno's Daisho: "return that card ... if it's a Samurai +/// card") is a resolution-time effect gate, NOT an intervening-if. The subtype +/// recognizer must only fire at the leading position; a trailing clause must be +/// left in the effect text (returned unchanged) with no trigger condition +/// extracted, so the ability still triggers and goes on the stack unconditionally. +#[test] +fn trigger_trailing_subtype_conditional_is_not_intervening_if() { + let effect = "return that card to the battlefield under your control if it's a samurai card"; + let (without_if, condition) = extract_if_condition(effect); + assert!( + condition.is_none(), + "trailing '... if it's a Samurai card' must not be lifted to a trigger \ + condition, got {condition:?}", + ); + assert_eq!( + without_if, effect, + "trailing conditional must remain in the effect text for the resolution-time gate", + ); +} + +/// CR 603.4 + CR 603.10: A recognized "if it's [not] a " intervening-if +/// must lower to the evaluator whose authority matches the TRIGGER KIND. For a +/// zone-change trigger (dies/leaves — Otherworldly Escort: "when this dies, if +/// it's not a Spirit") the subject "it" must be judged from the EVENT SNAPSHOT via +/// `ZoneChangeObjectMatchesFilter`; for a non-zone event (Captain Marvel's +/// `CounterAdded` "if it's not a Kree") it is judged live via +/// `EventObjectMatchesFilter`. Routing a zone-change subject through the live +/// matcher would judge a same-ID re-entrant, not the object that left. +#[test] +fn subtype_intervening_if_dispatches_by_trigger_kind() { + use crate::types::zones::Zone; + + // Non-zone event (no trigger_zone_change): live `EventObjectMatchesFilter`. + let (_, non_zone) = extract_if_condition("if it's not a kree, draw a card"); + let Some(TriggerCondition::Not { condition }) = non_zone else { + panic!("expected negated condition, got {non_zone:?}"); + }; + assert!( + matches!( + *condition, + TriggerCondition::EventObjectMatchesFilter { .. } + ), + "non-zone event must use the live EventObjectMatchesFilter, got {condition:?}", + ); + + // Zone-change trigger (dies): event-snapshot `ZoneChangeObjectMatchesFilter`. + let (_, zone_change) = extract_if_condition_with_card_name( + "if it's not a spirit, draw a card", + "", + None, + Some((Zone::Battlefield, Zone::Graveyard)), + ); + let Some(TriggerCondition::Not { condition }) = zone_change else { + panic!("expected negated condition, got {zone_change:?}"); + }; + assert!( + matches!( + *condition, + TriggerCondition::ZoneChangeObjectMatchesFilter { + destination: Zone::Graveyard, + .. + } + ), + "zone-change trigger must use the event-snapshot ZoneChangeObjectMatchesFilter, \ + got {condition:?}", + ); +} + /// Issue #551 — The Raven Man: "At the beginning of each end step, if a /// player discarded a card this turn, create a 1/1 black Bird ...". The /// "a player" (any player) intervening-if must be hoisted as an all-players diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 4ed11bb88e..dbabca9fcd 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -1796,6 +1796,24 @@ pub enum CounterMoveSelection { ResolutionDistributionAnyNumber, } +/// CR 122.1 + CR 603.2c: The per-kind magnitude axis for +/// `Effect::ReproduceEventCounters`. Reproduces the counters a triggering +/// counter-placement event just put onto a creature. A typed axis (never a bare +/// `Option`) so the two class members are self-documenting and exhaustively +/// matchable, mirroring `CounterTransferMode`/`CounterMoveSelection`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum EventCounterReproductionCount { + /// "put the same number and kind of counters" — reproduce each kind with the + /// exact count the event placed (Captain Marvel, Apex Avenger; Bold + /// Plagiarist). + #[default] + SameNumber, + /// "put one of each of those kinds of counters" — reproduce each distinct + /// kind exactly `n` times regardless of the event's per-kind count (Aragorn, + /// Company Leader: `PerKind(1)`). + PerKind(u32), +} + /// CR 701.6 + CR 608.2c: A follow-up instruction carried by `Effect::Counter` /// that acts on the *source permanent* of an ability countered by the effect. /// @@ -11705,6 +11723,22 @@ pub enum Effect { #[serde(default = "default_target_filter_any")] target: TargetFilter, }, + /// CR 122.1 + CR 603.2c + CR 608.2h: "put the same number and kind of + /// counters"/"put one of each of those kinds of counters" — reproduce onto + /// `target` the counters that the triggering counter-placement event just put + /// onto the recipient creature. Unlike `MoveCounters` (which reads an + /// object's TOTAL counter map), this reads the DELTA carried by the trigger's + /// `GameEvent::CounterAdded` occurrences in `state.current_trigger_events`, + /// so putting 1 more counter on a creature already holding 5 reproduces 1. + /// The multiset (kind → count) is snapshotted from the firing's events + /// (CR 608.2h). Covers the "reproduce the counters just placed" trigger class + /// (Captain Marvel, Apex Avenger; Bold Plagiarist; Aragorn, Company Leader). + ReproduceEventCounters { + #[serde(default = "default_target_filter_any")] + target: TargetFilter, + #[serde(default)] + per_kind_count: EventCounterReproductionCount, + }, Animate { /// CR 613.4 / Layer 7b: fixed base power. Use `PtValue::Fixed(n)` for known /// values and `PtValue::Quantity(q)` for dynamic quantities (e.g. CostXPaid, @@ -14876,6 +14910,11 @@ impl Effect { | Effect::MultiplyCounter { target, .. } | Effect::DoublePT { target, .. } | Effect::MoveCounters { target, .. } + // CR 122.1 + CR 603.2c: reproduce onto `target` (SelfRef for Captain + // Marvel; a real "up to one other target creature" for Aragorn). + // Surfaced so the SelfRef/stack target slot builds exactly like + // `PutCounter`. + | Effect::ReproduceEventCounters { target, .. } | Effect::Animate { target, .. } | Effect::Discard { target, .. } | Effect::Shuffle { target, .. } @@ -16004,6 +16043,9 @@ impl Effect { | Effect::ApplyPerpetual { .. } | Effect::DraftFromSpellbook { .. } | Effect::ChooseOneOf { .. } + // CR 122.1: the per-kind magnitude is `EventCounterReproductionCount`, + // not a `QuantityExpr`, so there is nothing to visit here. + | Effect::ReproduceEventCounters { .. } | Effect::Unimplemented { .. } => {} } } @@ -16270,6 +16312,9 @@ impl Effect { | Effect::VentureIntoDungeon | Effect::CombineHost { .. } | Effect::ChooseAugmentAndCombineWithHost { .. } + // CR 122.1: per-kind magnitude is `EventCounterReproductionCount`, + // not a `QuantityExpr`. + | Effect::ReproduceEventCounters { .. } | Effect::WinTheGame { .. } => None, } } @@ -16527,6 +16572,9 @@ impl Effect { | Effect::VentureIntoDungeon | Effect::CombineHost { .. } | Effect::ChooseAugmentAndCombineWithHost { .. } + // CR 122.1: per-kind magnitude is `EventCounterReproductionCount`, + // not a `QuantityExpr`. + | Effect::ReproduceEventCounters { .. } | Effect::WinTheGame { .. } => None, } } @@ -16624,6 +16672,7 @@ pub fn effect_variant_name(effect: &Effect) -> &str { Effect::DoublePT { .. } => "DoublePT", Effect::DoublePTAll { .. } => "DoublePTAll", Effect::MoveCounters { .. } => "MoveCounters", + Effect::ReproduceEventCounters { .. } => "ReproduceEventCounters", Effect::Animate { .. } => "Animate", Effect::ReturnAsAura { .. } => "ReturnAsAura", Effect::RegisterBending { .. } => "RegisterBending", @@ -16875,6 +16924,7 @@ pub enum EffectKind { DoublePT, DoublePTAll, MoveCounters, + ReproduceEventCounters, Animate, ReturnAsAura, RegisterBending, @@ -17132,6 +17182,7 @@ impl From<&Effect> for EffectKind { Effect::DoublePT { .. } => EffectKind::DoublePT, Effect::DoublePTAll { .. } => EffectKind::DoublePTAll, Effect::MoveCounters { .. } => EffectKind::MoveCounters, + Effect::ReproduceEventCounters { .. } => EffectKind::ReproduceEventCounters, Effect::Animate { .. } => EffectKind::Animate, Effect::ReturnAsAura { .. } => EffectKind::ReturnAsAura, Effect::RegisterBending { .. } => EffectKind::RegisterBending, diff --git a/crates/engine/src/types/events.rs b/crates/engine/src/types/events.rs index 5c597bd5a2..72f331f9df 100644 --- a/crates/engine/src/types/events.rs +++ b/crates/engine/src/types/events.rs @@ -1036,6 +1036,11 @@ pub enum GameEvent { object_id: ObjectId, counter_type: CounterType, count: u32, + // CR 122.1 + CR 603.2c: the player who put the counters, so "whenever + // you/an opponent put one or more counters" triggers can gate on the + // actor. Defaults to `PlayerId(0)` on pre-field serialized fixtures. + #[serde(default)] + actor: PlayerId, }, /// Digital-only Alchemy (no CR entry): a card's intensity increased by /// `amount`. Emitted per affected card so consumers (triggers that watch for diff --git a/crates/engine/src/types/resolution.rs b/crates/engine/src/types/resolution.rs index 0f668d3e91..d75fc02503 100644 --- a/crates/engine/src/types/resolution.rs +++ b/crates/engine/src/types/resolution.rs @@ -66,6 +66,13 @@ pub struct OptionalEffectFrame { pub ability: Box, #[serde(default, skip_serializing_if = "Option::is_none")] pub trigger_event: Option, + /// CR 603.2c + CR 608.2: the plural batched-trigger event list mirroring + /// `GameState::current_trigger_events`, so an effect that reads the whole + /// event batch (e.g. `Effect::ReproduceEventCounters`) still sees every + /// occurrence when the "may" decision resumes resolution — the singular + /// `trigger_event` alone drops the batch's other occurrences. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub trigger_events: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub trigger_match_count: Option, } @@ -3143,6 +3150,9 @@ impl LegacyOptionalEffectWire { Ok(Some(OptionalEffectFrame { ability, trigger_event: self.pending_optional_trigger_event, + // Legacy wire predates the plural batch; empty is the correct default + // (no in-flight reproduction on a legacy-serialized optional frame). + trigger_events: Vec::new(), trigger_match_count: self.pending_optional_trigger_match_count, })) } @@ -4249,6 +4259,7 @@ mod tests { optional_effect.push_inner(ResolutionFrame::OptionalEffect(OptionalEffectFrame { ability: Box::new(resolved_draw(6)), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, })); optional_effect @@ -4667,6 +4678,7 @@ mod tests { OptionalEffectFrame { ability: Box::new(resolved_draw(102)), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }, ); @@ -5493,6 +5505,7 @@ mod tests { buried_optional_frames.push_inner(ResolutionFrame::OptionalEffect(OptionalEffectFrame { ability: Box::new(resolved_draw(151)), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, })); buried_optional_frames.push_inner(continuation_frame(151)); diff --git a/crates/engine/tests/integration/captain_marvel_apex_avenger.rs b/crates/engine/tests/integration/captain_marvel_apex_avenger.rs new file mode 100644 index 0000000000..fb9fb87665 --- /dev/null +++ b/crates/engine/tests/integration/captain_marvel_apex_avenger.rs @@ -0,0 +1,1047 @@ +//! Runtime cast-pipeline + parser-shape coverage for Captain Marvel, Apex Avenger +//! and the "reproduce the counters just placed" trigger class +//! (`Effect::ReproduceEventCounters`, CR 122.1 + CR 603.2c + CR 608.2h). +//! +//! Verbatim Oracle text (Scryfall, MSC #78): line 1 is three evergreen keywords; +//! line 2 is the whole task — "Whenever you put one or more counters on another +//! creature, if it's not a Kree, you may put the same number and kind of counters +//! on Captain Marvel." +//! +//! Built via the `/card-test` recipe: `GameScenario` + +//! `GameRunner::cast(..).resolve()` + `CastOutcome` counter deltas, on verbatim +//! Oracle text. Every negative assertion is paired with a positive reach-guard in +//! the same test (a sibling placement that DOES reproduce), so an upstream parse +//! failure cannot satisfy it vacuously. +//! +//! REVERT DISCRIMINATORS: +//! - `reproduces_same_kind_and_count_onto_itself` — neutralize +//! `resolve_reproduce_event_counters` (or the matcher / parser) and Captain +//! Marvel gains 0 counters; the `assert_counters(cm, .., 2)` fails. +//! - `multi_recipient_fires_once_per_recipient` — revert the per-recipient +//! grouping to the all-in-one batched arm and the two `ReproduceEventCounters` +//! resolutions collapse to one; the `== 2` firing-count assertion fails. +//! (Because Captain Marvel's target is `SelfRef`, per-recipient and all-in-one +//! are equivalent on the counter TOTAL — the firing COUNT is the only +//! observable that discriminates them, hence the event-count assertion.) +//! - `multi_kind_single_creature_fires_exactly_once` — revert `def.batched` to a +//! non-batched (per-event) firing and the single multi-kind placement fires +//! twice; the `== 1` firing-count assertion fails. + +use engine::game::scenario::{CastOutcome, GameScenario, P0, P1}; +use engine::parser::parse_oracle_text; +use engine::types::ability::{ + ControllerRef, Effect, EffectKind, EventCounterReproductionCount, MultiTargetSpec, + QuantityExpr, TargetFilter, TriggerCondition, +}; +use engine::types::counter::CounterType; +use engine::types::events::GameEvent; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; +use engine::types::triggers::TriggerMode; + +/// Captain Marvel, Apex Avenger {5}{R}{W} — Legendary Creature — Human Kree Hero, +/// 4/4. Verbatim Oracle text. +const CAPTAIN_MARVEL: &str = "Flying, double strike, indestructible\nWhenever you put one or more \ + counters on another creature, if it's not a Kree, you may put the \ + same number and kind of counters on Captain Marvel."; + +/// Put Captain Marvel onto `player`'s battlefield with its verbatim Oracle text. +fn add_captain_marvel( + scenario: &mut GameScenario, + player: engine::types::player::PlayerId, +) -> ObjectId { + scenario + .add_creature_from_oracle(player, "Captain Marvel, Apex Avenger", 4, 4, CAPTAIN_MARVEL) + .id() +} + +/// Count how many `ReproduceEventCounters` effects resolved during the cast — the +/// number of times Captain Marvel's trigger fired (CR 603.2c firing granularity). +fn reproduction_firings(outcome: &CastOutcome) -> usize { + outcome + .events() + .iter() + .filter(|e| { + matches!( + e, + GameEvent::EffectResolved { + kind: EffectKind::ReproduceEventCounters, + .. + } + ) + }) + .count() +} + +/// Cast a single-target counter spell (P0) at `recipient` and drive Captain +/// Marvel's resulting "may" trigger with `accept`. Returns the outcome plus the +/// Captain Marvel and recipient object ids. +fn cast_at_recipient( + recipient_subtypes: &[&str], + spell_oracle: &str, + accept: bool, +) -> (CastOutcome, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let cm = add_captain_marvel(&mut scenario, P0); + let recipient = scenario + .add_creature(P0, "Recipient Bear", 2, 2) + .with_subtypes(recipient_subtypes.to_vec()) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Bolster Rite", true, spell_oracle) + .id(); + let mut runner = scenario.build(); + + let cast = runner.cast(spell).target_objects(&[recipient]); + let cast = if accept { cast.accept_optional() } else { cast }; + let outcome = cast.resolve(); + (outcome, cm, recipient) +} + +// --------------------------------------------------------------------------- +// #1 — the primary revert-discriminator: reproduce same kind + count onto self. +// --------------------------------------------------------------------------- + +#[test] +fn reproduces_same_kind_and_count_onto_itself() { + let (outcome, cm, recipient) = + cast_at_recipient(&[], "Put two +1/+1 counters on target creature.", true); + // Reach guard: the counter placement happened, so Captain Marvel's trigger + // had a live event to observe. + outcome.assert_counters(recipient, CounterType::Plus1Plus1, 2); + // The fix: same number AND kind reproduced onto Captain Marvel. + outcome.assert_counters(cm, CounterType::Plus1Plus1, 2); + // Exactly one firing (one non-Kree recipient). + assert_eq!(reproduction_firings(&outcome), 1); +} + +/// Kind fidelity (matrix #1 hostile): a non-P/T counter reproduces as that kind, +/// not as +1/+1. +#[test] +fn reproduces_the_exact_kind_placed() { + let (outcome, cm, recipient) = + cast_at_recipient(&[], "Put a shield counter on target creature.", true); + outcome.assert_counters(recipient, CounterType::Shield, 1); + outcome.assert_counters(cm, CounterType::Shield, 1); + // No spurious +1/+1 reproduction. + outcome.assert_counters(cm, CounterType::Plus1Plus1, 0); +} + +// --------------------------------------------------------------------------- +// #2 — DELTA, not total (distinguishes from MoveCounters, which reads the +// recipient's whole counter map). +// --------------------------------------------------------------------------- + +#[test] +fn reproduces_the_event_delta_not_the_recipients_total() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let cm = add_captain_marvel(&mut scenario, P0); + // Recipient already holds five +1/+1 counters. + let recipient = scenario + .add_creature(P0, "Recipient Bear", 2, 2) + .with_plus_counters(5) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Bolster Rite", + true, + "Put a +1/+1 counter on target creature.", + ) + .id(); + let mut runner = scenario.build(); + let outcome = runner + .cast(spell) + .target_objects(&[recipient]) + .accept_optional() + .resolve(); + + // Recipient now holds 6 (5 pre-existing + 1 placed). + outcome.assert_counters(recipient, CounterType::Plus1Plus1, 6); + // Captain Marvel reproduces only the DELTA the event placed (1), not the + // recipient's total (6) — CR 122.1 delta semantics. + outcome.assert_counters(cm, CounterType::Plus1Plus1, 1); +} + +// --------------------------------------------------------------------------- +// #4 — intervening-if "if it's not a Kree" at trigger time (paired positive + +// negative in one test). +// --------------------------------------------------------------------------- + +#[test] +fn intervening_if_gates_on_kree_recipient() { + // Negative: recipient IS a Kree → no reproduction. + let (kree_outcome, cm_kree, kree_recipient) = cast_at_recipient( + &["Kree"], + "Put two +1/+1 counters on target creature.", + true, + ); + kree_outcome.assert_counters(kree_recipient, CounterType::Plus1Plus1, 2); + kree_outcome.assert_counters(cm_kree, CounterType::Plus1Plus1, 0); + assert_eq!( + reproduction_firings(&kree_outcome), + 0, + "a Kree recipient must not fire the reproduction" + ); + + // Positive reach-guard (same class, different fixture): a non-Kree recipient + // DOES reproduce — proving the negative above is the gate, not a dead trigger. + let (ok_outcome, cm_ok, _) = cast_at_recipient( + &["Beast"], + "Put two +1/+1 counters on target creature.", + true, + ); + ok_outcome.assert_counters(cm_ok, CounterType::Plus1Plus1, 2); + assert_eq!(reproduction_firings(&ok_outcome), 1); +} + +// --------------------------------------------------------------------------- +// #6 — "you may" optionality (paired accept + decline). +// --------------------------------------------------------------------------- + +#[test] +fn may_optionality_accept_and_decline() { + // Decline: Captain Marvel gains nothing, even though a legal reproduction + // was available (recipient got its counters — the reach guard). + let (declined, cm_dec, recipient_dec) = + cast_at_recipient(&[], "Put two +1/+1 counters on target creature.", false); + declined.assert_counters(recipient_dec, CounterType::Plus1Plus1, 2); + declined.assert_counters(cm_dec, CounterType::Plus1Plus1, 0); + + // Accept: Captain Marvel reproduces. + let (accepted, cm_acc, _) = + cast_at_recipient(&[], "Put two +1/+1 counters on target creature.", true); + accepted.assert_counters(cm_acc, CounterType::Plus1Plus1, 2); +} + +// --------------------------------------------------------------------------- +// #7 — recipient filter "another creature" excludes Captain Marvel itself. +// --------------------------------------------------------------------------- + +#[test] +fn another_creature_excludes_self() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let cm = add_captain_marvel(&mut scenario, P0); + // Reach guard: a genuine other creature so the trigger is demonstrably live. + let other = scenario.add_creature(P0, "Other Bear", 2, 2).id(); + let self_spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Bolster Rite", + true, + "Put two +1/+1 counters on target creature.", + ) + .id(); + let other_spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Bolster Rite Two", + true, + "Put two +1/+1 counters on target creature.", + ) + .id(); + let mut runner = scenario.build(); + + // Placing counters directly on Captain Marvel must NOT fire the trigger + // ("another creature" excludes the source). Captain Marvel holds exactly the + // two placed by the spell — not four. + let on_self = runner + .cast(self_spell) + .target_objects(&[cm]) + .accept_optional() + .resolve(); + on_self.assert_counters(cm, CounterType::Plus1Plus1, 2); + assert_eq!( + reproduction_firings(&on_self), + 0, + "putting counters on Captain Marvel itself must not fire (another creature)" + ); + + // Reach guard: placing on the OTHER creature does reproduce → Captain Marvel + // gains two more (total 4), proving the trigger is live and the self case + // above was gated by "another creature", not a dead trigger. + let on_other = runner + .cast(other_spell) + .target_objects(&[other]) + .accept_optional() + .resolve(); + on_other.assert_counters(cm, CounterType::Plus1Plus1, 4); + assert_eq!(reproduction_firings(&on_other), 1); +} + +// --------------------------------------------------------------------------- +// #3 — actor gate "you put" (paired opponent-actor negative + your-actor +// positive). The opponent case is driven by making P1 the active player. +// --------------------------------------------------------------------------- + +#[test] +fn actor_gate_you_versus_opponent() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let cm = add_captain_marvel(&mut scenario, P0); + let opp_bear = scenario.add_creature(P1, "Opp Bear", 2, 2).id(); + let you_bear = scenario.add_creature(P0, "Your Bear", 2, 2).id(); + let opp_spell = scenario + .add_spell_to_hand_from_oracle( + P1, + "Opp Bolster", + true, + "Put two +1/+1 counters on target creature.", + ) + .id(); + let you_spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Your Bolster", + true, + "Put two +1/+1 counters on target creature.", + ) + .id(); + let mut runner = scenario.build(); + + // Opponent (P1) places the counters. Make P1 the active player with priority. + { + let state = runner.state_mut(); + state.active_player = P1; + state.priority_player = P1; + state.waiting_for = WaitingFor::Priority { player: P1 }; + } + let opp_outcome = runner + .cast(opp_spell) + .target_objects(&[opp_bear]) + .accept_optional() + .resolve(); + // CR 603.2c: "whenever YOU put" does not fire on the opponent's placement. + opp_outcome.assert_counters(opp_bear, CounterType::Plus1Plus1, 2); + opp_outcome.assert_counters(cm, CounterType::Plus1Plus1, 0); + assert_eq!( + reproduction_firings(&opp_outcome), + 0, + "opponent-placed counters must not fire a 'whenever you put' trigger" + ); + + // Reach guard: when YOU (P0) place the counters, the trigger fires — proving + // the gate above rejected the actor, not a dead trigger. + { + let state = runner.state_mut(); + state.active_player = P0; + state.priority_player = P0; + state.waiting_for = WaitingFor::Priority { player: P0 }; + } + let you_outcome = runner + .cast(you_spell) + .target_objects(&[you_bear]) + .accept_optional() + .resolve(); + you_outcome.assert_counters(cm, CounterType::Plus1Plus1, 2); + assert_eq!(reproduction_firings(&you_outcome), 1); +} + +// --------------------------------------------------------------------------- +// #12 — multi-KIND placement on ONE creature fires EXACTLY ONCE (per-recipient +// granularity vs. a non-batched per-event firing). +// --------------------------------------------------------------------------- + +#[test] +fn multi_kind_single_creature_fires_exactly_once() { + let (outcome, cm, recipient) = cast_at_recipient( + &[], + "Put a +1/+1 counter and a shield counter on target creature.", + true, + ); + // Both kinds land on the recipient in one placement event batch. + outcome.assert_counters(recipient, CounterType::Plus1Plus1, 1); + outcome.assert_counters(recipient, CounterType::Shield, 1); + // Captain Marvel gains BOTH kinds from a SINGLE firing (one "may" decision, + // one reproduction folding the recipient's whole multiset). + outcome.assert_counters(cm, CounterType::Plus1Plus1, 1); + outcome.assert_counters(cm, CounterType::Shield, 1); + assert_eq!( + reproduction_firings(&outcome), + 1, + "a single multi-kind placement on one creature must fire the reproduction \ + exactly once (CR 603.2c per-recipient, not per-kind)" + ); +} + +/// PRODUCTION FRAME (CR 603.2c + CR 608.2): Captain Marvel's optional ("may") +/// reproduction suspends into an `OptionalEffectFrame`; on accept, the frame's +/// PLURAL `trigger_events` batch is restored to `current_trigger_events` +/// (`engine_payment_choices.rs`) so the resumed reproduction folds EVERY captured +/// `CounterAdded` occurrence. A multi-kind placement produces a multi-event batch; +/// reverting the plural restoration leaves the resumed resolution with only the +/// singular event, so at most one kind reproduces and the assertions below fail. +/// (The frame unit tests use `trigger_events: Vec::new()` and cannot catch this.) +#[test] +fn optional_frame_restores_full_multi_event_batch_on_accept() { + // Multi-event batch: a +1/+1 (count 2) event AND a shield (count 1) event. + let (outcome, cm, recipient) = cast_at_recipient( + &[], + "Put two +1/+1 counters and a shield counter on target creature.", + true, + ); + // Reach guard: the whole batch landed on the recipient. + outcome.assert_counters(recipient, CounterType::Plus1Plus1, 2); + outcome.assert_counters(recipient, CounterType::Shield, 1); + // After suspend+accept, EVERY captured event reproduced onto Captain Marvel — + // both the +1/+1 (count 2) and the shield (count 1). Dropping the plural + // restoration would lose one of these. + outcome.assert_counters(cm, CounterType::Plus1Plus1, 2); + outcome.assert_counters(cm, CounterType::Shield, 1); + assert_eq!( + reproduction_firings(&outcome), + 1, + "one recipient → one 'may' decision → one reproduction folding the whole batch", + ); +} + +// --------------------------------------------------------------------------- +// #13 — multi-RECIPIENT placement fires ONCE PER RECIPIENT (per-recipient +// grouping vs. the all-in-one batched arm). +// --------------------------------------------------------------------------- + +#[test] +fn multi_recipient_fires_once_per_recipient() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let cm = add_captain_marvel(&mut scenario, P0); + let bear_a = scenario.add_creature(P0, "Bear A", 2, 2).id(); + let bear_b = scenario.add_creature(P0, "Bear B", 2, 2).id(); + let spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Twin Bolster", + true, + "Put a +1/+1 counter on each of up to two target creatures.", + ) + .id(); + let mut runner = scenario.build(); + let outcome = runner + .cast(spell) + .target_objects(&[bear_a, bear_b]) + .accept_optional() + .resolve(); + + // Both recipients got their counter (reach guard: the placement happened on + // two distinct creatures in one event batch). + outcome.assert_counters(bear_a, CounterType::Plus1Plus1, 1); + outcome.assert_counters(bear_b, CounterType::Plus1Plus1, 1); + // CR 603.2c: one firing PER recipient — two separate "may" reproductions. + assert_eq!( + reproduction_firings(&outcome), + 2, + "one counter-placement event on two recipients must fire the reproduction \ + once per recipient (CR 603.2c), not once for the whole batch" + ); + // Each firing reproduces its recipient's single +1/+1 → two onto Captain Marvel. + outcome.assert_counters(cm, CounterType::Plus1Plus1, 2); +} + +// --------------------------------------------------------------------------- +// #9 — parser SHAPE. Labeled SHAPE: asserts the parsed trigger structure via +// typed accessors (not internal dual-encoded bools). +// --------------------------------------------------------------------------- + +#[test] +fn parse_shape_matches_reproduction_class() { + let parsed = parse_oracle_text( + CAPTAIN_MARVEL, + "Captain Marvel, Apex Avenger", + &[], + &["Creature".to_string()], + &["Human".to_string(), "Kree".to_string(), "Hero".to_string()], + ); + let trig = parsed + .triggers + .iter() + .find(|t| t.mode == TriggerMode::CounterAdded) + .expect("Captain Marvel has a CounterAdded trigger"); + + // Actor gate: "you put". + assert_eq!(trig.valid_target, Some(TargetFilter::Controller)); + // Recipient filter is "another creature" — a real filter, not self, not unset. + assert!( + matches!(&trig.valid_card, Some(f) if !matches!(f, TargetFilter::SelfRef)), + "valid_card should be 'another creature', got {:?}", + trig.valid_card + ); + // "you may". + assert!(trig.optional); + // Stays batched so the per-recipient firing path is taken. + assert!(trig.batched, "reproduction trigger must remain batched"); + // Intervening-if "if it's not a Kree" → Not(EventObjectMatchesFilter{..}). + assert!( + matches!( + &trig.condition, + Some(TriggerCondition::Not { condition }) + if matches!(**condition, TriggerCondition::EventObjectMatchesFilter { .. }) + ), + "condition should be Not(EventObjectMatchesFilter), got {:?}", + trig.condition + ); + // Effect: reproduce onto self, same number and kind, with ZERO Unimplemented. + let effect = trig + .execute + .as_deref() + .map(|a| a.effect.as_ref()) + .expect("trigger has an execute effect"); + assert!( + matches!( + effect, + Effect::ReproduceEventCounters { + target: TargetFilter::SelfRef, + per_kind_count: EventCounterReproductionCount::SameNumber, + } + ), + "effect should be ReproduceEventCounters{{SelfRef, SameNumber}}, got {effect:?}" + ); +} + +/// The "an opponent puts" sibling parses `valid_target == Opponent` (Bold +/// Plagiarist actor axis) — the negative of the "you put" shape above. +#[test] +fn parse_shape_opponent_actor_axis() { + let parsed = parse_oracle_text( + "Whenever an opponent puts one or more counters on a creature, you may draw a card.", + "Test Watcher", + &[], + &["Creature".to_string()], + &[], + ); + let trig = parsed + .triggers + .iter() + .find(|t| t.mode == TriggerMode::CounterAdded) + .expect("opponent-actor CounterAdded trigger"); + assert_eq!(trig.valid_target, Some(TargetFilter::Opponent)); +} + +// --------------------------------------------------------------------------- +// #14 — parser precedence: "if it's not a token" stays the zone-change token +// condition (finding 5), while "if it's not a " routes to the new +// event-object combinator. +// --------------------------------------------------------------------------- + +#[test] +fn parser_precedence_token_versus_subtype() { + // "not a token" must NOT be consumed by the new subtype combinator — it falls + // through to the pre-existing zone-change token condition (CR 111.1). + let token_parsed = parse_oracle_text( + "Whenever you put one or more counters on another creature, if it's not a token, you may \ + draw a card.", + "Token Watcher", + &[], + &["Creature".to_string()], + &[], + ); + let token_trig = token_parsed + .triggers + .iter() + .find(|t| t.mode == TriggerMode::CounterAdded) + .expect("token-condition CounterAdded trigger"); + assert!( + matches!( + &token_trig.condition, + Some(TriggerCondition::ZoneChangeObjectMatchesFilter { .. }) + ), + "\"if it's not a token\" must route to the zone-change token condition, got {:?}", + token_trig.condition + ); + + // A recognized subtype ("Kree") routes to the new event-object combinator — + // proving the subtype path is reached first for recognized types. + let kree_parsed = parse_oracle_text( + "Whenever you put one or more counters on another creature, if it's not a Kree, you may \ + draw a card.", + "Kree Watcher", + &[], + &["Creature".to_string()], + &[], + ); + let kree_trig = kree_parsed + .triggers + .iter() + .find(|t| t.mode == TriggerMode::CounterAdded) + .expect("Kree-condition CounterAdded trigger"); + assert!( + matches!( + &kree_trig.condition, + Some(TriggerCondition::Not { condition }) + if matches!(**condition, TriggerCondition::EventObjectMatchesFilter { .. }) + ), + "\"if it's not a Kree\" must route to Not(EventObjectMatchesFilter), got {:?}", + kree_trig.condition + ); +} + +// =========================================================================== +// Bold Plagiarist — sibling of the reproduction class with an OPPONENT actor +// gate and a "they control" recipient anaphor bound to that opponent. +// +// Verbatim Oracle text (Scryfall / MTGJSON): line 1 is Flash; line 2 is the +// task — "Whenever an opponent puts one or more counters on a creature they +// control, they put the same number and kind of counters on this creature." +// "they" = the opponent who placed the counters (the actor), so the recipient +// filter is "a creature the OPPONENT controls", not one you control. +// =========================================================================== + +const BOLD_PLAGIARIST: &str = + "Flash\nWhenever an opponent puts one or more counters on a creature \ + they control, they put the same number and kind of counters on this \ + creature."; + +/// Top-level `controller` scope of a `TargetFilter::Typed`, if any. +fn typed_controller(filter: &Option) -> Option { + match filter { + Some(TargetFilter::Typed(tf)) => tf.controller.clone(), + _ => None, + } +} + +/// SHAPE: the "they control" recipient anaphor binds to the OPPONENT actor gate, +/// not to `You`. Reverting the actor→`relative_player_scope` bridge in +/// `try_parse_counter_trigger` flips `valid_card`'s controller back to `You`, +/// firing on your creatures instead of the opponent's — this assertion fails. +#[test] +fn bold_plagiarist_recipient_filter_binds_to_opponent() { + let parsed = parse_oracle_text( + BOLD_PLAGIARIST, + "Bold Plagiarist", + &[], + &["Creature".to_string()], + &["Human".to_string(), "Rogue".to_string()], + ); + let trig = parsed + .triggers + .iter() + .find(|t| t.mode == TriggerMode::CounterAdded) + .expect("Bold Plagiarist has a CounterAdded trigger"); + // Actor gate: "an opponent puts". + assert_eq!(trig.valid_target, Some(TargetFilter::Opponent)); + // Recipient: "a creature they control" — the opponent's creature. + assert_eq!( + typed_controller(&trig.valid_card), + Some(ControllerRef::Opponent), + "\"they control\" must bind to the opponent actor, got {:?}", + trig.valid_card + ); +} + +/// Drive an opponent (P1) casting "Put two +1/+1 counters on target creature" +/// at a creature owned by `recipient_owner`, while P0's Bold Plagiarist watches. +/// Returns `(outcome, bold_plagiarist_id, recipient_id)`. +fn bold_plagiarist_opponent_places_on( + recipient_owner: engine::types::player::PlayerId, +) -> (CastOutcome, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let bp = scenario + .add_creature_from_oracle(P0, "Bold Plagiarist", 3, 2, BOLD_PLAGIARIST) + .id(); + let recipient = scenario + .add_creature(recipient_owner, "Recipient Bear", 2, 2) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle( + P1, + "Opp Bolster", + true, + "Put two +1/+1 counters on target creature.", + ) + .id(); + let mut runner = scenario.build(); + // The opponent (P1) is the active player placing the counters. + { + let state = runner.state_mut(); + state.active_player = P1; + state.priority_player = P1; + state.waiting_for = WaitingFor::Priority { player: P1 }; + } + let outcome = runner.cast(spell).target_objects(&[recipient]).resolve(); + (outcome, bp, recipient) +} + +/// RUNTIME discriminator for the "they control" fix: the recipient-controller +/// gate must bind to the placing opponent, not to you. Both halves flip if the +/// binding is reverted to `You`. +#[test] +fn bold_plagiarist_binds_they_control_to_the_placing_opponent() { + // Positive: the opponent places counters on THEIR OWN creature → Bold + // Plagiarist reproduces the same number and kind onto itself (SelfRef). + // (Under the reverted `You` binding this would NOT fire, since the recipient + // is controlled by the opponent — so this assertion is a revert discriminator.) + let (own, bp_own, opp_bear) = bold_plagiarist_opponent_places_on(P1); + own.assert_counters(opp_bear, CounterType::Plus1Plus1, 2); // reach guard + own.assert_counters(bp_own, CounterType::Plus1Plus1, 2); + assert_eq!( + reproduction_firings(&own), + 1, + "an opponent placing counters on a creature THEY control must fire" + ); + + // Negative discriminator: the opponent places counters on a creature YOU + // control. The actor gate (opponent) still passes, so only the "they + // control" recipient gate can suppress it — and it must. (Under the reverted + // `You` binding this WOULD wrongly fire.) + let (yours, bp_yours, your_bear) = bold_plagiarist_opponent_places_on(P0); + yours.assert_counters(your_bear, CounterType::Plus1Plus1, 2); // reach guard + yours.assert_counters(bp_yours, CounterType::Plus1Plus1, 0); + assert_eq!( + reproduction_firings(&yours), + 0, + "an opponent placing counters on a creature YOU control must not fire \ + (\"they\" is the opponent, not you)" + ); +} + +// =========================================================================== +// Aragorn, Company Leader — the PerKind + targeted (non-SelfRef) sub-class. +// +// Verbatim Oracle text (Scryfall / MTGJSON): the reproduction trigger is +// "Whenever you put one or more counters on Aragorn, put one of each of those +// kinds of counters on up to one other target creature." PerKind(1) ignores the +// event's per-kind magnitude and the effect targets another creature (not self). +// =========================================================================== + +const ARAGORN: &str = "Whenever the Ring tempts you, if you chose a creature other than Aragorn as \ + your Ring-bearer, put your choice of a counter from among first strike, \ + vigilance, deathtouch, and lifelink on Aragorn.\nWhenever you put one or more \ + counters on Aragorn, put one of each of those kinds of counters on up to one \ + other target creature."; + +/// SHAPE: Aragorn reproduces one of EACH KIND (PerKind(1)) onto ANOTHER target +/// creature, gated on counters placed on Aragorn itself ("you put … on Aragorn"). +#[test] +fn aragorn_reproduction_shape_is_per_kind_to_target() { + let parsed = parse_oracle_text( + ARAGORN, + "Aragorn, Company Leader", + &[], + &["Creature".to_string()], + &[ + "Human".to_string(), + "Noble".to_string(), + "Ranger".to_string(), + ], + ); + let trig = parsed + .triggers + .iter() + .find(|t| t.mode == TriggerMode::CounterAdded) + .expect("Aragorn has a CounterAdded reproduction trigger"); + // Fires on counters placed on Aragorn itself. + assert_eq!(trig.valid_card, Some(TargetFilter::SelfRef)); + // "you put". + assert_eq!(trig.valid_target, Some(TargetFilter::Controller)); + let effect = trig + .execute + .as_deref() + .map(|a| a.effect.as_ref()) + .expect("trigger has an execute effect"); + match effect { + Effect::ReproduceEventCounters { + target, + per_kind_count, + } => { + assert_eq!(*per_kind_count, EventCounterReproductionCount::PerKind(1)); + assert!( + !matches!(target, TargetFilter::SelfRef), + "Aragorn reproduces onto another target creature, not self; got {target:?}" + ); + } + other => panic!("expected ReproduceEventCounters, got {other:?}"), + } +} + +/// PARSE (CR 115.1d + CR 601.2c): Aragorn's "on up to one other target creature" +/// must stamp `MultiTargetSpec::up_to(1)` on the reproduction ability so the +/// target slot is genuinely optional (min=0, max=1). Without this the target is +/// mandatory and the controller cannot decline it. Asserting the parsed +/// cardinality directly (not just runtime behavior with an undeclared target, +/// which passes vacuously) is what proves the `MultiTargetSpec` survives lowering. +#[test] +fn aragorn_reproduction_target_is_optional_up_to_one() { + let parsed = parse_oracle_text( + ARAGORN, + "Aragorn, Company Leader", + &[], + &["Creature".to_string()], + &[ + "Human".to_string(), + "Noble".to_string(), + "Ranger".to_string(), + ], + ); + let trig = parsed + .triggers + .iter() + .find(|t| t.mode == TriggerMode::CounterAdded) + .expect("Aragorn has a CounterAdded reproduction trigger"); + let execute = trig + .execute + .as_deref() + .expect("trigger has an execute ability"); + assert!( + matches!( + execute.effect.as_ref(), + Effect::ReproduceEventCounters { .. } + ), + "reach guard: the reproduction effect must be present, got {:?}", + execute.effect + ); + assert_eq!( + execute.multi_target, + Some(MultiTargetSpec::up_to(QuantityExpr::Fixed { value: 1 })), + "\"up to one other target creature\" must stamp MultiTargetSpec::up_to(1) \ + so the reproduction target is optional (min=0, max=1)", + ); +} + +/// RUNTIME: a multi-kind, count>1 placement on Aragorn reproduces exactly ONE of +/// each KIND (not the count) onto the chosen target creature. Reverting the +/// `PerKind` fold arm (counters.rs) to `SameNumber` makes the target gain TWO +/// +1/+1 counters and this assertion fails. +#[test] +fn aragorn_per_kind_reproduction_places_one_of_each_kind_on_target() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let aragorn = scenario + .add_creature_from_oracle(P0, "Aragorn, Company Leader", 3, 3, ARAGORN) + .id(); + let ally = scenario.add_creature(P0, "Ally Bear", 2, 2).id(); + let spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Twin Bolster", + true, + "Put two +1/+1 counters and a shield counter on target creature.", + ) + .id(); + let mut runner = scenario.build(); + // Spell target (Aragorn) is consumed first; the reproduction trigger's "up + // to one other target creature" slot then consumes the ally. + let outcome = runner + .cast(spell) + .target_objects(&[aragorn, ally]) + .resolve(); + + // Reach guard: the multi-kind, count-2 placement landed on Aragorn. + outcome.assert_counters(aragorn, CounterType::Plus1Plus1, 2); + outcome.assert_counters(aragorn, CounterType::Shield, 1); + // PerKind(1): exactly ONE of each KIND on the target — the +1/+1 count of 2 + // is deliberately ignored (this is the SameNumber vs PerKind discriminator). + outcome.assert_counters(ally, CounterType::Plus1Plus1, 1); + outcome.assert_counters(ally, CounterType::Shield, 1); + assert_eq!(reproduction_firings(&outcome), 1); +} + +/// RUNTIME: the mandatory reproduction still fires, but "up to one other target +/// creature" may choose zero targets — reproducing nothing. Pairs the zero-target +/// negative with a positive reach guard (the trigger fired) so it is not vacuous. +#[test] +fn aragorn_up_to_one_target_declined_reproduces_nothing() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let aragorn = scenario + .add_creature_from_oracle(P0, "Aragorn, Company Leader", 3, 3, ARAGORN) + .id(); + let bystander = scenario.add_creature(P0, "Bystander Bear", 2, 2).id(); + let spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Solo Bolster", + true, + "Put a shield counter on target creature.", + ) + .id(); + let mut runner = scenario.build(); + // Only Aragorn is declared. The optional "up to one other target creature" + // slot receives no declared object, so the reproduction chooses zero targets. + let outcome = runner.cast(spell).target_objects(&[aragorn]).resolve(); + + // Reach guard: counters landed on Aragorn and the mandatory trigger fired. + outcome.assert_counters(aragorn, CounterType::Shield, 1); + assert_eq!( + reproduction_firings(&outcome), + 1, + "the mandatory reproduction fires even when 'up to one' targets nothing" + ); + // Zero-target resolution places no counters on any other creature. + outcome.assert_counters(bystander, CounterType::Shield, 0); +} + +// =========================================================================== +// Compound reproduction primary — the reproduction is the PRIMARY clause of a +// compound ("... on up to one other target creature AND draw a card"). This +// clause returns from `try_split_targeted_compound` before the direct-clause +// multi_target fixup, so the splitter's own primary-cardinality recovery must +// also cover `ReproduceEventCounters` (mirrors the `PutCounter` recovery). +// =========================================================================== + +/// Synthetic compound: Aragorn's optional-target reproduction primary followed by +/// a second conjunct. No printed card pairs these today, so this exercises the +/// building block (the compound split + primary-cardinality recovery), not a card. +const COMPOUND_REPRODUCER: &str = "Whenever you put one or more counters on Compound Reproducer, \ + put one of each of those kinds of counters on up to one other \ + target creature and draw a card."; + +/// PARSE (CR 115.1d + CR 122.1, compound route): the primary reproduction clause +/// must retain `MultiTargetSpec::up_to(1)` even when a trailing conjunct pushes it +/// through `try_split_targeted_compound`, and the conjunct must survive as a +/// sub-ability. Reverting the `ReproduceEventCounters` arm of the splitter's +/// primary-cardinality recovery drops the bound and this assertion fails. +#[test] +fn compound_reproduction_primary_retains_up_to_one() { + let parsed = parse_oracle_text( + COMPOUND_REPRODUCER, + "Compound Reproducer", + &[], + &["Creature".to_string()], + &["Human".to_string()], + ); + let trig = parsed + .triggers + .iter() + .find(|t| t.mode == TriggerMode::CounterAdded) + .expect("compound reproducer has a CounterAdded trigger"); + let execute = trig + .execute + .as_deref() + .expect("trigger has an execute ability"); + assert!( + matches!( + execute.effect.as_ref(), + Effect::ReproduceEventCounters { .. } + ), + "primary clause must be the reproduction effect, got {:?}", + execute.effect + ); + assert_eq!( + execute.multi_target, + Some(MultiTargetSpec::up_to(QuantityExpr::Fixed { value: 1 })), + "compound reproduction primary must retain MultiTargetSpec::up_to(1)", + ); + assert!( + execute.sub_ability.is_some(), + "the trailing \"and draw a card\" conjunct must survive as a sub-ability", + ); +} + +/// RUNTIME (compound, one target): the optional slot takes a target, so the chosen +/// creature gains the reproduced counter AND the mandatory second conjunct draws. +#[test] +fn compound_reproduction_one_target_reproduces_and_draws() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_library_top(P0, &["Reward Card"]); + let reproducer = scenario + .add_creature_from_oracle(P0, "Compound Reproducer", 3, 3, COMPOUND_REPRODUCER) + .id(); + let ally = scenario.add_creature(P0, "Ally Bear", 2, 2).id(); + let spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Solo Bolster", + true, + "Put a shield counter on target creature.", + ) + .id(); + let mut runner = scenario.build(); + // Spell target (reproducer) consumed first; the "up to one other target + // creature" slot then consumes the ally. + let outcome = runner + .cast(spell) + .target_objects(&[reproducer, ally]) + .resolve(); + outcome.assert_counters(reproducer, CounterType::Shield, 1); + // Reproduced onto the chosen optional target. + outcome.assert_counters(ally, CounterType::Shield, 1); + // The mandatory second conjunct resolved. + outcome.assert_hand_drawn(P0, 1); +} + +/// RUNTIME (compound, zero targets): the optional slot is declined, so nothing is +/// reproduced, but the mandatory second conjunct still draws — proving the target +/// is optional (min=0) without suppressing the rest of the compound. +#[test] +fn compound_reproduction_zero_targets_still_draws() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_library_top(P0, &["Reward Card"]); + let reproducer = scenario + .add_creature_from_oracle(P0, "Compound Reproducer", 3, 3, COMPOUND_REPRODUCER) + .id(); + let bystander = scenario.add_creature(P0, "Bystander Bear", 2, 2).id(); + let spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Solo Bolster", + true, + "Put a shield counter on target creature.", + ) + .id(); + let mut runner = scenario.build(); + // Only the reproducer is declared; the optional reproduction slot gets nothing. + let outcome = runner.cast(spell).target_objects(&[reproducer]).resolve(); + outcome.assert_counters(reproducer, CounterType::Shield, 1); + // Zero-target reproduction places no counters elsewhere... + outcome.assert_counters(bystander, CounterType::Shield, 0); + // ...but the mandatory second conjunct still resolved. + outcome.assert_hand_drawn(P0, 1); +} + +// =========================================================================== +// Captain Marvel — mixed multi-recipient placement (one Kree + one non-Kree) +// exercising the per-recipient intervening-if in the batched grouping path. +// =========================================================================== + +/// RUNTIME (finding: per-recipient intervening-if): a single placement event on +/// two recipients — one Kree, one non-Kree — fires the reproduction ONCE, for +/// the non-Kree recipient only. The Kree recipient's event is filtered by the +/// per-candidate "if it's not a Kree" gate inside +/// `matching_counter_added_events_by_recipient`, so Captain Marvel gains only the +/// non-Kree recipient's multiset. +#[test] +fn mixed_kree_and_non_kree_recipients_fire_only_for_non_kree() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let cm = add_captain_marvel(&mut scenario, P0); + let kree = scenario + .add_creature(P0, "Kree Bear", 2, 2) + .with_subtypes(vec!["Kree"]) + .id(); + let beast = scenario + .add_creature(P0, "Beast Bear", 2, 2) + .with_subtypes(vec!["Beast"]) + .id(); + let spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Twin Bolster", + true, + "Put a +1/+1 counter on each of up to two target creatures.", + ) + .id(); + let mut runner = scenario.build(); + let outcome = runner + .cast(spell) + .target_objects(&[kree, beast]) + .accept_optional() + .resolve(); + + // Reach guard: both recipients got their counter in one event batch. + outcome.assert_counters(kree, CounterType::Plus1Plus1, 1); + outcome.assert_counters(beast, CounterType::Plus1Plus1, 1); + // Only the non-Kree recipient fires the reproduction (per-recipient gate). + assert_eq!( + reproduction_firings(&outcome), + 1, + "the Kree recipient must be suppressed per-recipient; only the non-Kree fires" + ); + // Captain Marvel gains only the non-Kree recipient's single +1/+1. + outcome.assert_counters(cm, CounterType::Plus1Plus1, 1); +} diff --git a/crates/engine/tests/integration/cr733_resolved_frame_transition.rs b/crates/engine/tests/integration/cr733_resolved_frame_transition.rs index d17b22ae1e..a5c3c362cc 100644 --- a/crates/engine/tests/integration/cr733_resolved_frame_transition.rs +++ b/crates/engine/tests/integration/cr733_resolved_frame_transition.rs @@ -153,6 +153,7 @@ fn optional_effect_frame_cannot_survive_into_search_choice_parent_insertion() { .push_inner(ResolutionFrame::OptionalEffect(OptionalEffectFrame { ability: optional_ability, trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, })); state.waiting_for = WaitingFor::SearchChoice { diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 38a7059789..35188beaa7 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -62,6 +62,7 @@ mod bring_to_light_free_cast_2880; mod calamity_of_the_titans_reveal_cost; mod call_damage_control_modal_return; mod captain_america_throw; +mod captain_marvel_apex_avenger; mod cascade_intervening_if_pipeline; mod case_solve_condition; mod cast_during_resolution_pipeline; diff --git a/crates/engine/tests/integration/optional_effect_remember_legal_actions.rs b/crates/engine/tests/integration/optional_effect_remember_legal_actions.rs index d047f128ab..2912bf1b1c 100644 --- a/crates/engine/tests/integration/optional_effect_remember_legal_actions.rs +++ b/crates/engine/tests/integration/optional_effect_remember_legal_actions.rs @@ -48,6 +48,7 @@ fn keyed_optional_effect_exposes_and_resolves_remember_choices() { state.push_optional_effect_frame(engine::types::OptionalEffectFrame { ability: Box::new(ability), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); diff --git a/crates/phase-ai/src/policies/effect_classify.rs b/crates/phase-ai/src/policies/effect_classify.rs index ee7a0c4a18..585003a399 100644 --- a/crates/phase-ai/src/policies/effect_classify.rs +++ b/crates/phase-ai/src/policies/effect_classify.rs @@ -76,6 +76,16 @@ pub(crate) fn effect_polarity(effect: &Effect) -> EffectPolarity { Effect::PutCounter { counter_type, .. } | Effect::PutCounterAll { counter_type, .. } => { counter_sign_polarity(counter_type) } + // CR 122.1: the reproduced counter KIND is event-derived at resolution — + // there is no static `counter_type` to sign, and the triggering event can + // carry a harmful kind (e.g. -1/-1). The `target` is also not necessarily + // self: Aragorn, Company Leader reproduces onto "up to one OTHER target + // creature", so the effect can land on a creature the controller does not + // want buffed/debuffed. Neither the sign nor the recipient is knowable + // until the policy holds the selected target and the triggering multiset, + // so classify as Contextual and let the call site (e.g. anti_self_harm) + // inspect both rather than assuming a self-buff. + Effect::ReproduceEventCounters { .. } => EffectPolarity::Contextual, // CR 122.1 + CR 121: Removing counters inverts the placement polarity — // removing a +1/+1 counter harms the bearer, removing a -1/-1 counter // helps it (Hexcaster's Mark, Solemnity-style interactions, Vampire diff --git a/crates/phase-ai/src/policies/redundancy_avoidance.rs b/crates/phase-ai/src/policies/redundancy_avoidance.rs index dd1b50d804..615ebc1c36 100644 --- a/crates/phase-ai/src/policies/redundancy_avoidance.rs +++ b/crates/phase-ai/src/policies/redundancy_avoidance.rs @@ -464,6 +464,9 @@ fn redundancy_delta( | Effect::ChooseCard { .. } | Effect::PutCounterAll { .. } | Effect::MultiplyCounter { .. } + // CR 122.1 + CR 603.2c: reproduction has no static zero-count redundancy + // check (the count is event-derived). + | Effect::ReproduceEventCounters { .. } | Effect::DoublePT { .. } | Effect::DoublePTAll { .. } | Effect::MoveCounters { .. } diff --git a/crates/phase-ai/src/policies/sacrifice_value.rs b/crates/phase-ai/src/policies/sacrifice_value.rs index 02b542b7c3..90b23f9923 100644 --- a/crates/phase-ai/src/policies/sacrifice_value.rs +++ b/crates/phase-ai/src/policies/sacrifice_value.rs @@ -673,6 +673,7 @@ mod tests { state.push_optional_effect_frame(engine::types::OptionalEffectFrame { ability: Box::new(sacrifice), trigger_event: None, + trigger_events: Vec::new(), trigger_match_count: None, }); state.waiting_for = WaitingFor::OptionalEffectChoice { diff --git a/crates/phase-ai/src/policies/tests/effect_classify_snapshot.rs b/crates/phase-ai/src/policies/tests/effect_classify_snapshot.rs index 37e7a2788c..81b05fed82 100644 --- a/crates/phase-ai/src/policies/tests/effect_classify_snapshot.rs +++ b/crates/phase-ai/src/policies/tests/effect_classify_snapshot.rs @@ -17,7 +17,9 @@ //! `SetTapState`), and a spread of formerly-wildcarded variants now proven //! to return `Contextual`. -use engine::types::ability::{Effect, EffectScope, QuantityExpr, TapStateChange, TargetFilter}; +use engine::types::ability::{ + Effect, EffectScope, EventCounterReproductionCount, QuantityExpr, TapStateChange, TargetFilter, +}; use engine::types::counter::CounterType; use engine::types::zones::{EtbTapState, Zone}; @@ -82,6 +84,34 @@ fn beneficial_classifications_unchanged() { ); } +/// CR 122.1: `ReproduceEventCounters` is Contextual across its whole design space, +/// not a static self-buff. The reproduced kind is event-derived (can be harmful) +/// and the target may be another creature (Aragorn's "up to one other target +/// creature"), so neither the sign nor the recipient is knowable at classify time. +/// Discriminating coverage over both axes — target {SelfRef, other} × per-kind +/// {SameNumber, PerKind} — guards against a regression that re-hardcodes any form +/// to Beneficial/Harmful. +#[test] +fn reproduce_event_counters_is_contextual_across_axes() { + for target in [TargetFilter::SelfRef, TargetFilter::Any] { + for per_kind_count in [ + EventCounterReproductionCount::SameNumber, + EventCounterReproductionCount::PerKind(1), + ] { + let effect = Effect::ReproduceEventCounters { + target: target.clone(), + per_kind_count, + }; + assert_eq!( + effect_polarity(&effect), + EffectPolarity::Contextual, + "{effect:?} must classify as Contextual (event-derived kind, \ + possibly non-self target)", + ); + } + } +} + #[test] fn harmful_classifications_unchanged() { assert_eq!(