diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index 14a23554d1..35d4b1265a 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -7217,44 +7217,65 @@ pub fn synthesize_sunburst(face: &mut CardFace) { .filter(|r| is_sunburst_etb_replacement(r, &counter_type)) .count(); - let counter_phrase = match &counter_type { + for _ in existing..instances { + face.replacements + .push(sunburst_replacement_definition(&counter_type)); + } +} + +/// CR 702.44a + CR 702.44d + CR 601.2h: the single per-instance Sunburst ETB +/// replacement definition — one `Moved`→Battlefield replacement on `SelfRef` +/// whose execute places one counter of `counter_type` per distinct color of +/// mana spent to cast the object. +/// +/// The single authority for building a Sunburst replacement, shared by: +/// - build-time synthesis (`synthesize_sunburst`) for PRINTED Sunburst, and +/// - the runtime granted-keyword replacement path +/// (`granted_sunburst_instances` → `granted_etb_replacement_definitions` → +/// `find_applicable_replacements`), which +/// surfaces one virtual candidate per *granted* Sunburst instance so a grant +/// ("that spell gains sunburst": Solar Array / Lux Artillery) also places +/// counters at entry (#5337). +/// +/// Because both callers build identical definitions, printed + granted +/// instances each yield a distinct candidate and apply separately, exactly as +/// CR 702.44d ("If an object has multiple instances of sunburst, each one works +/// separately") requires. +pub(crate) fn sunburst_replacement_definition(counter_type: &CounterType) -> ReplacementDefinition { + let counter_phrase = match counter_type { CounterType::Plus1Plus1 => "+1/+1", _ => "charge", }; - - for _ in existing..instances { - let etb_counters = AbilityDefinition::new( - AbilityKind::Spell, - Effect::PutCounter { - counter_type: counter_type.clone(), - // CR 702.44a + CR 601.2h: one counter per *color* (max 5) of mana - // spent to cast this object — the distinct-colors metric, not the - // total amount. - count: QuantityExpr::Ref { - qty: QuantityRef::ManaSpentToCast { - scope: CastManaObjectScope::SelfObject, - metric: CastManaSpentMetric::DistinctColors, - }, + let etb_counters = AbilityDefinition::new( + AbilityKind::Spell, + Effect::PutCounter { + counter_type: counter_type.clone(), + // CR 702.44a + CR 601.2h: one counter per *color* (max 5) of mana + // spent to cast this object — the distinct-colors metric, not the + // total amount. + count: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope: CastManaObjectScope::SelfObject, + metric: CastManaSpentMetric::DistinctColors, }, - target: TargetFilter::SelfRef, }, - ) - .description(format!( - "This permanent enters with a {counter_phrase} counter on it for each color of mana spent to cast it" - )); + target: TargetFilter::SelfRef, + }, + ) + .description(format!( + "This permanent enters with a {counter_phrase} counter on it for each color of mana spent to cast it" + )); - let replacement = ReplacementDefinition { - event: ReplacementEvent::Moved, - execute: Some(Box::new(etb_counters)), - valid_card: Some(TargetFilter::SelfRef), - // CR 614.1c: battlefield-entry-scoped (departure gate). - destination_zone: Some(Zone::Battlefield), - description: Some(format!( - "CR 702.44a: Sunburst — this permanent enters with a {counter_phrase} counter on it for each color of mana spent to cast it." - )), - ..ReplacementDefinition::new(ReplacementEvent::Moved) - }; - face.replacements.push(replacement); + ReplacementDefinition { + event: ReplacementEvent::Moved, + execute: Some(Box::new(etb_counters)), + valid_card: Some(TargetFilter::SelfRef), + // CR 614.1c: battlefield-entry-scoped (departure gate). + destination_zone: Some(Zone::Battlefield), + description: Some(format!( + "CR 702.44a: Sunburst — this permanent enters with a {counter_phrase} counter on it for each color of mana spent to cast it." + )), + ..ReplacementDefinition::new(ReplacementEvent::Moved) } } @@ -8115,27 +8136,51 @@ pub fn synthesize_bloodthirst(face: &mut CardFace) { if existing >= needed { continue; } - let etb_counters = AbilityDefinition::new( - AbilityKind::Spell, - Effect::PutCounter { - counter_type: CounterType::Plus1Plus1, - count: bloodthirst_counter_quantity(value), - target: TargetFilter::SelfRef, - }, - ) - .description(bloodthirst_execute_description(value)); + face.replacements + .push(bloodthirst_replacement_definition(value)); + } +} - let replacement = ReplacementDefinition { - event: ReplacementEvent::Moved, - execute: Some(Box::new(etb_counters)), - valid_card: Some(TargetFilter::SelfRef), - condition: bloodthirst_condition(value), - // CR 614.1c: battlefield-entry-scoped (departure gate). - destination_zone: Some(Zone::Battlefield), - description: Some(bloodthirst_replacement_description(value)), - ..ReplacementDefinition::new(ReplacementEvent::Moved) - }; - face.replacements.push(replacement); +/// CR 702.54a + CR 702.54b + CR 702.54c: the single per-instance Bloodthirst ETB +/// replacement definition — one `Moved`→Battlefield replacement on `SelfRef` +/// whose execute places `bloodthirst_counter_quantity(value)` +1/+1 counters, +/// gated by `bloodthirst_condition(value)` (the fixed-N form is conditional on an +/// opponent having been dealt damage this turn; the X form is unconditional and +/// its count reads the damage total directly). +/// +/// The single authority for building a Bloodthirst replacement, shared by: +/// - build-time synthesis (`synthesize_bloodthirst`) for PRINTED Bloodthirst, and +/// - the runtime granted-keyword replacement path +/// (`granted_bloodthirst_instances` → `find_applicable_replacements`), which +/// surfaces one virtual candidate per *granted* Bloodthirst instance so a grant +/// ("it gains bloodthirst 3": Bloodlord of Vaasgoth) also places counters at +/// entry (mirrors `sunburst_replacement_definition`, #5802). +/// +/// Because both callers build identical definitions, printed + granted instances +/// each yield a distinct candidate and apply separately per CR 702.54c ("each +/// instance works separately"). +pub(crate) fn bloodthirst_replacement_definition( + value: &BloodthirstValue, +) -> ReplacementDefinition { + let etb_counters = AbilityDefinition::new( + AbilityKind::Spell, + Effect::PutCounter { + counter_type: CounterType::Plus1Plus1, + count: bloodthirst_counter_quantity(value), + target: TargetFilter::SelfRef, + }, + ) + .description(bloodthirst_execute_description(value)); + + ReplacementDefinition { + event: ReplacementEvent::Moved, + execute: Some(Box::new(etb_counters)), + valid_card: Some(TargetFilter::SelfRef), + condition: bloodthirst_condition(value), + // CR 614.1c: battlefield-entry-scoped (departure gate). + destination_zone: Some(Zone::Battlefield), + description: Some(bloodthirst_replacement_description(value)), + ..ReplacementDefinition::new(ReplacementEvent::Moved) } } diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 6e7f427dbe..6360b6290e 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -6228,7 +6228,7 @@ fn apply_continuous_effect_filtered( // Ward/Annihilator each apply independently). `evaluate_layers` resets // `obj.keywords = obj.base_keywords.clone()` each pass, so this never // accumulates unbounded across re-evaluations. - if resolved_keyword.sums_across_instances() { + if resolved_keyword.instances_must_coexist() { obj.keywords.push(resolved_keyword.clone()); } else if resolved_keyword.overrides_same_kind_on_grant() { // CR 613.7: this grant is a single-authoritative-value keyword — the @@ -6325,7 +6325,7 @@ fn apply_continuous_effect_filtered( for (keyword_output_index, kw) in add_chosen_keywords.iter().enumerate() { // CR 702.164b: summing keywords (Toxic) accumulate rather // than dedup, mirroring the plain `AddKeyword` arm above. - if kw.sums_across_instances() || !obj.keywords.contains(kw) { + if kw.instances_must_coexist() || !obj.keywords.contains(kw) { obj.keywords.push(kw.clone()); } for (companion_index, trigger) in KeywordTriggerInstaller::triggers_for(kw) diff --git a/crates/engine/src/game/off_zone_characteristics.rs b/crates/engine/src/game/off_zone_characteristics.rs index d6edc74ae2..70419cfebc 100644 --- a/crates/engine/src/game/off_zone_characteristics.rs +++ b/crates/engine/src/game/off_zone_characteristics.rs @@ -374,7 +374,7 @@ fn upsert_keyword_contribution( // granted off-zone Toxic pushes rather than clobbering an unrelated printed // keyword that shares its (Unknown) kind. Non-summing keywords keep the // upsert-by-kind dedup below unchanged. - if !contribution.keyword.sums_across_instances() { + if !contribution.keyword.instances_must_coexist() { if let Some(existing) = keywords .iter_mut() .find(|existing| existing.keyword.kind() == contribution.keyword.kind()) diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 7f53b1e119..33137c879d 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -69,6 +69,34 @@ const FINALITY_COUNTER_INDEX: usize = usize::MAX - 5; /// card-granted `ReplacementDefinition`, so it uses the existing virtual-ID /// protocol shared by intrinsic shield, finality, and compleated effects. const COMMANDER_HAND_OR_LIBRARY_RETURN_INDEX: usize = usize::MAX - 6; +/// CR 702.44a + CR 702.44d: Granted Sunburst — a virtual `Moved`→Battlefield +/// ETB-counter replacement keyed on the entering spell that was GRANTED +/// sunburst ("that spell gains sunburst": Solar Array / Lux Artillery). Printed +/// sunburst is baked as an object-carried `ReplacementDefinition` at synthesis +/// time (`synthesize_sunburst`); a runtime grant adds a keyword but no +/// replacement definition, so this reserved candidate surfaces one virtual ETB +/// replacement per GRANTED instance (base-subtracted, mirroring +/// `synthesize_granted_keyword_triggers`). CR 702.44d — printed + granted +/// instances each yield a distinct candidate and apply separately. Only the +/// entering object's own granted sunburst is at issue, so the reserved index is +/// keyed on the entering object. +/// +/// Sunburst and Bloodthirst share the SAME structural gap — a printed as-enters +/// keyword synthesized into object-carried replacements, versus a runtime grant +/// that adds only the keyword — so they share the count/apply core +/// (`granted_keyword_etb_instances`, `apply_granted_keyword_etb_replacement`); +/// only the reserved index and per-instance-definition builder differ. Any future +/// granted as-enters keyword adds one more reserved index feeding the same core. +const GRANTED_SUNBURST_INDEX: usize = usize::MAX - 7; +/// CR 702.54a + CR 702.54c: Granted Bloodthirst — the Bloodthirst analogue of +/// `GRANTED_SUNBURST_INDEX`. Bloodlord of Vaasgoth's "Whenever you cast a Vampire +/// creature spell, it gains bloodthirst 3" adds only the keyword to the cast +/// spell; printed Bloodthirst is synthesized into carried replacements by +/// `synthesize_bloodthirst`, so this reserved candidate surfaces one virtual ETB +/// replacement per GRANTED Bloodthirst instance. Unlike Sunburst, the fixed-N +/// form is CONDITIONAL (an opponent must have been dealt damage this turn), so the +/// shared applier honors each granted instance's carried `condition`. +const GRANTED_BLOODTHIRST_INDEX: usize = usize::MAX - 8; /// CR 109.4 + CR 108.4a: Cards outside the battlefield/stack have no /// controller; if an effect asks for a card's controller, use its owner @@ -222,6 +250,174 @@ fn object_has_finality_counter(state: &GameState, object_id: ObjectId) -> bool { .is_some_and(|count| *count > 0) } +/// The reserved virtual-candidate index for each granted as-enters keyword family +/// (Sunburst, Bloodthirst). One reserved id per keyword feeds the shared +/// count/apply core, so the applier recovers WHICH keyword's per-instance +/// definitions to place from `rid.index` alone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GrantedEtbKeyword { + Sunburst, + Bloodthirst, +} + +impl GrantedEtbKeyword { + fn from_index(index: usize) -> Option { + match index { + GRANTED_SUNBURST_INDEX => Some(Self::Sunburst), + GRANTED_BLOODTHIRST_INDEX => Some(Self::Bloodthirst), + _ => None, + } + } + + fn index(self) -> usize { + match self { + Self::Sunburst => GRANTED_SUNBURST_INDEX, + Self::Bloodthirst => GRANTED_BLOODTHIRST_INDEX, + } + } +} + +fn granted_etb_keyword_replacement_id(object_id: ObjectId, kw: GrantedEtbKeyword) -> ReplacementId { + ReplacementId { + source: object_id, + index: kw.index(), + } +} + +fn is_granted_etb_keyword_replacement(rid: ReplacementId) -> bool { + GrantedEtbKeyword::from_index(rid.index).is_some() +} + +/// CR 604.1 + CR 613.1f: The count of GRANTED instances of `keyword` on +/// `object_id` matching `predicate` — the object's EFFECTIVE matching-keyword +/// count minus its printed (base) matching count. +/// +/// Printed as-enters keywords (Sunburst, Bloodthirst) are realized through +/// object-carried `ReplacementDefinition`s at synthesis time; only the granted +/// instances need a virtual candidate, so subtract `base_keywords` (mirrors +/// `synthesize_granted_keyword_triggers`). A printed-only keyword returns 0 here — +/// its counters come from the carried definitions, never this path — which keeps +/// printed + granted double-applying (CR 702.44d / CR 702.54c). +/// +/// CRITICAL: the entering spell is still on the STACK when its entry replacement +/// pipeline runs, and a granted keyword exists only as a continuous effect at that +/// moment — `obj.keywords` is NOT yet materialized for stack objects. +/// `effective_off_zone_keywords` is the single authority that resolves the live +/// keyword list for any zone (materialized list for battlefield objects; base + +/// ordered continuous grants, including transient effects, for stack/off-zone +/// objects — CR 613.1f recursion-guarded). +/// +/// `predicate` keys the count to a specific keyword identity: `Sunburst` is +/// parameter-less (match the variant); `Bloodthirst(v)` must match a distinct +/// value so a granted `bloodthirst 3` on top of a printed `bloodthirst 1` counts +/// one granted 3 and one printed 1 separately (CR 702.54c). +fn granted_keyword_etb_instances( + state: &GameState, + object_id: ObjectId, + live_keywords: &[crate::types::keywords::Keyword], + predicate: impl Fn(&crate::types::keywords::Keyword) -> bool, +) -> usize { + let Some(obj) = state.objects.get(&object_id) else { + return 0; + }; + let live = live_keywords.iter().filter(|kw| predicate(kw)).count(); + let base = obj.base_keywords.iter().filter(|kw| predicate(kw)).count(); + live.saturating_sub(base) +} + +/// CR 702.44d: number of GRANTED sunburst instances (parameter-less). +fn granted_sunburst_instances( + state: &GameState, + object_id: ObjectId, + live_keywords: &[crate::types::keywords::Keyword], +) -> usize { + granted_keyword_etb_instances(state, object_id, live_keywords, |kw| { + matches!(kw, crate::types::keywords::Keyword::Sunburst) + }) +} + +/// CR 702.54c: The GRANTED Bloodthirst instances on `object_id`, one entry per +/// granted instance carrying its `BloodthirstValue`. Counted per DISTINCT value +/// (effective-minus-base per value) exactly as `synthesize_bloodthirst` emits one +/// printed replacement per value, so a granted `bloodthirst 3` on a printed +/// `bloodthirst 1` yields exactly one granted-3 entry here. +fn granted_bloodthirst_instances( + state: &GameState, + object_id: ObjectId, + live_keywords: &[crate::types::keywords::Keyword], +) -> Vec { + use crate::types::keywords::Keyword; + let Some(obj) = state.objects.get(&object_id) else { + return Vec::new(); + }; + let live = live_keywords; + let distinct_values: Vec<_> = live + .iter() + .filter_map(|kw| match kw { + Keyword::Bloodthirst(v) => Some(v.clone()), + _ => None, + }) + .fold(Vec::new(), |mut acc, v| { + if !acc.contains(&v) { + acc.push(v); + } + acc + }); + let mut granted = Vec::new(); + for value in distinct_values { + let live_n = live + .iter() + .filter(|kw| matches!(kw, Keyword::Bloodthirst(v) if *v == value)) + .count(); + let base_n = obj + .base_keywords + .iter() + .filter(|kw| matches!(kw, Keyword::Bloodthirst(v) if *v == value)) + .count(); + for _ in 0..live_n.saturating_sub(base_n) { + granted.push(value.clone()); + } + } + granted +} + +/// Whether the granted `kw` virtual candidate should surface for `object_id` at +/// `event` — i.e. there is at least one granted instance whose carried condition +/// (if any) holds. This mirrors the PRINTED replacement path, which evaluates a +/// definition's `condition` at candidate-registration time and does not surface a +/// candidate whose condition is unmet (so a condition-unmet granted Bloodthirst +/// raises no spurious CR 616.1 ordering prompt). Sunburst definitions carry no +/// condition, so this reduces to "has at least one granted instance." +/// +/// The applier (`apply_granted_keyword_etb_replacement`) re-derives and re-checks +/// the same per-instance definitions, so registration and application agree. +fn granted_etb_keyword_candidate_applies( + state: &GameState, + object_id: ObjectId, + kw: GrantedEtbKeyword, + event: &ProposedEvent, + live_keywords: &[crate::types::keywords::Keyword], +) -> bool { + let controller = state + .objects + .get(&object_id) + .map(replacement_source_player) + .unwrap_or(state.active_player); + granted_etb_replacement_definitions(state, object_id, kw, live_keywords) + .iter() + .any(|definition| match &definition.condition { + Some(cond) => evaluate_replacement_condition( + cond, + controller, + object_id, + state, + event.affected_object_id(), + event, + ), + None => true, + }) +} + fn compleated_life_paid(state: &GameState, object_id: ObjectId) -> Option { state.objects.get(&object_id).and_then(|obj| { (obj.phyrexian_life_paid > 0 @@ -329,6 +525,168 @@ fn apply_compleated_replacement( } } +/// Build the per-instance `ReplacementDefinition`s for each GRANTED as-enters +/// keyword instance on `object_id`, using the same shared authority the printed +/// synthesizer uses so a granted spell places exactly the counters a printed one +/// would (CR 702.44d / CR 702.54c: each instance works separately). +/// +/// - Sunburst: N identical copies of `sunburst_replacement_definition`, branching +/// the counter type on the entering object's PRINTED core types (CR 702.44a). +/// - Bloodthirst: one `bloodthirst_replacement_definition(value)` per granted +/// instance, each carrying its own `condition` (CR 702.54a fixed-N is gated on +/// an opponent having been dealt damage this turn). +/// +/// `live_keywords` is the already-resolved off-zone keyword list for `object_id` +/// (`effective_off_zone_keywords`). It is threaded in rather than re-derived per +/// keyword family because that resolution runs a whole-game continuous-effect +/// collect, and this function sits on the `find_applicable_replacements` hot path. +fn granted_etb_replacement_definitions( + state: &GameState, + object_id: ObjectId, + kw: GrantedEtbKeyword, + live_keywords: &[crate::types::keywords::Keyword], +) -> Vec { + match kw { + GrantedEtbKeyword::Sunburst => { + let instances = granted_sunburst_instances(state, object_id, live_keywords); + // CR 702.44a: sunburst branches on whether the object is entering as a + // creature "ignoring any type-changing effects that would affect it" — + // i.e. on its PRINTED (characteristic-defining) core types. `card_types` + // is the LIVE layer result and type-changing effects do reach stack + // objects (CR 613.1d, via `remote_type_layer_recipients`), so reading it + // here would honor exactly the effects the rule says to ignore. + // `base_card_types` is seeded from the same `card_face.card_type` the + // printed synthesizer branches on (`synthesize_sunburst`), keeping the + // granted and printed paths identical. + let counter_type = state + .objects + .get(&object_id) + .filter(|obj| obj.base_card_types.core_types.contains(&CoreType::Creature)) + .map(|_| CounterType::Plus1Plus1) + .unwrap_or_else(|| CounterType::Generic("charge".to_string())); + let definition = + crate::database::synthesis::sunburst_replacement_definition(&counter_type); + std::iter::repeat_n(definition, instances).collect() + } + GrantedEtbKeyword::Bloodthirst => { + granted_bloodthirst_instances(state, object_id, live_keywords) + .iter() + .map(crate::database::synthesis::bloodthirst_replacement_definition) + .collect() + } + } +} + +/// CR 702.44a + CR 702.44d + CR 702.54a + CR 702.54c + CR 614.1c: Apply a granted +/// as-enters-keyword virtual ETB replacement (Sunburst or Bloodthirst) — fold the +/// as-enters counters onto the entering spell's `ZoneChange`, one placement group +/// per GRANTED instance. +/// +/// Each per-instance `ReplacementDefinition` comes from the same shared authority +/// the printed synthesizer uses (`granted_etb_replacement_definitions`), so a +/// granted spell places exactly the counters a printed one would; the count is +/// resolved by `event_modifiers_for_ability` against the entering spell so a +/// self-scoped quantity ref (Sunburst's `ManaSpentToCast`, Bloodthirst X's damage +/// total) reads its own cast/damage context (CR 601.2h). +/// +/// CR 702.54a — Bloodthirst is CONDITIONAL: each granted instance whose carried +/// `condition` is unmet (no opponent dealt damage this turn) contributes ZERO +/// counters, routed through the SAME `evaluate_replacement_condition` seam the +/// printed Bloodthirst path uses. Sunburst definitions carry `condition: None` +/// and are always applied. +/// +/// One `enter_with_counters` group is pushed per granted instance (CR 702.44d / +/// CR 702.54c: each instance works separately), so a counter-doubling replacement +/// (Doubling Season) doubles each instance's placement independently, exactly as +/// it would for multiple printed instances. +fn apply_granted_keyword_etb_replacement( + state: &mut GameState, + mut event: ProposedEvent, + rid: ReplacementId, + events: &mut Vec, +) -> ProposedEvent { + let Some(kw) = GrantedEtbKeyword::from_index(rid.index) else { + return event; + }; + // The candidate is keyed on the entering object; bail unchanged if the ids + // diverged (defensive) or the event is not the entering spell's ZoneChange. + let ProposedEvent::ZoneChange { object_id, .. } = &event else { + return event; + }; + if *object_id != rid.source { + return event; + } + + let live_keywords = + crate::game::off_zone_characteristics::effective_off_zone_keywords(state, rid.source); + let definitions = granted_etb_replacement_definitions(state, rid.source, kw, &live_keywords); + if definitions.is_empty() { + return event; + } + + // CR 110.2a: a battlefield-entry replacement's condition is evaluated relative + // to the entering object's controller (its owner while still on the stack). + let controller = state + .objects + .get(&rid.source) + .map(replacement_source_player) + .unwrap_or(state.active_player); + + // Resolve each granted instance's counter group, honoring its carried + // condition (CR 702.54a Bloodthirst gate), then fold them onto the event. + let mut instance_counter_groups: Vec> = Vec::new(); + for definition in &definitions { + // CR 614.1d + CR 702.54a: skip a granted instance whose condition is unmet + // (Bloodthirst fixed-N: no opponent dealt damage this turn). Sunburst's + // definition has `condition: None`, so it is always applied. + if let Some(cond) = &definition.condition { + if !evaluate_replacement_condition( + cond, + controller, + rid.source, + state, + event.affected_object_id(), + &event, + ) { + continue; + } + } + let modifiers = + event_modifiers_for_ability(definition.execute.as_deref(), state, rid.source, &event); + if !modifiers.etb_counters.is_empty() { + instance_counter_groups.push(modifiers.etb_counters); + } + } + if instance_counter_groups.is_empty() { + // No instance placed counters (e.g. Bloodthirst condition unmet, or zero + // colors of mana spent): the event passes through unchanged. `rid` is + // already recorded in `applied` by the pipeline's `mark_applied`. + return event; + } + + // Gemini nit (#5802 review): mutate `enter_with_counters` in place on the + // event rather than reconstructing every `ZoneChange` field — this survives + // new field additions to the variant (no field is manually re-listed). + if let ProposedEvent::ZoneChange { + enter_with_counters, + .. + } = &mut event + { + // CR 702.44d / CR 702.54c: one placement group per granted instance. + for group in instance_counter_groups { + enter_with_counters.extend(group); + } + } + // The candidate id is already recorded in `applied` by the pipeline's + // `mark_applied(rid)` before this applier runs (`for_event`-keyed), so the + // `applied` set is threaded through unchanged — no manual re-insert. + events.push(GameEvent::ReplacementApplied { + source_id: rid.source, + event_type: ReplacementEvent::Moved.to_string(), + }); + event +} + /// CR 614.1: Replacement effects modify events as they would occur. #[derive(Debug, Clone, PartialEq)] pub enum ReplacementResult { @@ -850,6 +1208,16 @@ fn replacement_choice_label_for_rid(state: &GameState, rid: ReplacementId) -> St if is_compleated_replacement(rid) { return "Compleated: enter with fewer loyalty counters".to_string(); } + if let Some(kw) = GrantedEtbKeyword::from_index(rid.index) { + // CR 702.44a / CR 702.54a: mandatory ETB-counter replacement — only ever + // labeled in a CR 616.1 ordering prompt, never an accept/decline choice. + return match kw { + GrantedEtbKeyword::Sunburst => { + "Sunburst: enter with counters for colors of mana spent".to_string() + } + GrantedEtbKeyword::Bloodthirst => "Bloodthirst: enter with +1/+1 counters".to_string(), + }; + } if is_finality_counter_replacement(rid) { return "Exile it instead".to_string(); } @@ -6407,6 +6775,52 @@ pub fn find_applicable_replacements( } } + // CR 702.44a + CR 702.44d + CR 702.54a + CR 702.54c + CR 614.1c: Granted + // as-enters keywords (Sunburst, Bloodthirst) — a spell GRANTED such a keyword + // as it was cast ("that spell gains sunburst": Solar Array / Lux Artillery; + // "it gains bloodthirst 3": Bloodlord of Vaasgoth) carries the keyword in its + // live keyword set but no object-carried ETB replacement (only printed keywords + // are synthesized into `replacement_definitions`). Surface one virtual + // ETB-counter candidate PER KEYWORD FAMILY here when the granted spell enters + // the battlefield so its as-enters counters are placed. Gated to + // `ZoneChange`→Battlefield exactly as the printed definition's `Moved`/ + // destination gate. One reserved candidate per family covers all that family's + // granted instances — its applier emits one counter placement per granted + // instance (CR 702.44d / CR 702.54c), and printed instances still apply + // separately via their own carried definitions. Ordered against Doubling + // Season-class modifiers by the shared enter-with-counters pipeline, exactly + // like the printed keyword. + if let ProposedEvent::ZoneChange { + object_id, + to: Zone::Battlefield, + .. + } = event + { + // Hot path (`find_applicable_replacements` runs per proposed event, and + // AI search clones/replays states constantly): test the CHEAP term first. + // `already_applied` is a set lookup, whereas the granted-instance query + // resolves the object's live off-zone keyword list — a whole-game + // continuous-effect collect plus ordering and per-effect filter + // evaluation. That resolution is also hoisted out of the family loop and + // computed at most ONCE (lazily, so an all-applied event pays nothing), + // then shared by every family instead of being re-swept per family. + let mut live_keywords: Option> = None; + for kw in [GrantedEtbKeyword::Sunburst, GrantedEtbKeyword::Bloodthirst] { + let rid = granted_etb_keyword_replacement_id(*object_id, kw); + if event.already_applied(&rid) { + continue; + } + let live = live_keywords.get_or_insert_with(|| { + crate::game::off_zone_characteristics::effective_off_zone_keywords( + state, *object_id, + ) + }); + if granted_etb_keyword_candidate_applies(state, *object_id, kw, event, live) { + candidates.push(rid); + } + } + } + // CR 702.89a: Umbra armor — a destroy of a permanent enchanted by an Umbra is // a candidate for the virtual umbra-armor replacement. Offered independently of // the shield-counter match above so a permanent carrying both a shield counter @@ -7151,6 +7565,12 @@ fn apply_single_replacement( return Ok(apply_compleated_replacement(state, proposed, rid, events)); } + if is_granted_etb_keyword_replacement(rid) { + return Ok(apply_granted_keyword_etb_replacement( + state, proposed, rid, events, + )); + } + if let Some(kind) = shield_counter_replacement_kind(rid) { return apply_shield_counter_replacement(state, proposed, rid, kind, events); } @@ -7927,6 +8347,19 @@ fn candidate_materiality( return CandidateMateriality::Unconditional; } + // CR 616.1 + CR 614.1c: a granted as-enters keyword (Sunburst / Bloodthirst) + // APPENDS to the event's counter payload — an ADDITIVE Count write. Two + // appenders commute (append 2 + append 3 = 5 either way), but an appender does + // NOT commute with a counter doubler on the same event ((0+N)*2 vs 0*2+N), so + // classifying it `Disjoint` would silently suppress the CR 616.1e ordering + // choice against a Doubling Season-class Count writer (review on #5802). + if is_granted_etb_keyword_replacement(rid) { + return CandidateMateriality::Writes { + field: EventField::Count, + commute: CommuteClass::Additive, + }; + } + // CR 614.10: the turn-scoped combat skip fully prevents the BeginPhase event, // so it is unconditional like the umbra-armor / shield-counter destroy. if is_turn_scoped_combat_skip_replacement(rid) { diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 7edf095edd..a271f38bb7 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -1318,11 +1318,21 @@ fn try_parse_when_next_event(tp: TextPair) -> Option { let effect_lower = after.lower; // Check for "that creature enters with an additional +1/+1 counter on it" pattern - let inner = if let Some(parsed) = try_parse_enters_with_additional_counters(effect_lower) { + let mut inner = if let Some(parsed) = try_parse_enters_with_additional_counters(effect_lower) { parsed } else { parse_effect_chain(effect_text, AbilityKind::Spell) }; + // CR 608.2k: In a "when you next cast a this turn" delayed trigger the + // "that " anaphor in the body names the newly-cast spell object (the + // trigger's event source), NOT a chosen target — a `WhenNextEvent` delayed + // trigger has no parent target to inherit. `parse_target` returns the + // subject-position anaphor as `ParentTarget` because trigger context is not + // threaded through the effect parser; lift it to `TriggeringSource` so the + // runtime binds the grant via `resolve_event_context_target` (effect.rs:259) + // instead of the empty chain-tracked set (effect.rs:474). Mirrors the + // `lift_parent_target_to_triggering_source` family in oracle_trigger.rs. + lift_generic_effect_parent_target_to_triggering_source_in_ability(&mut inner); if let Some((spell_filter, ability_filter)) = try_parse_when_next_spell_or_activate_disjunction(condition_fragment) @@ -1365,6 +1375,42 @@ fn try_parse_when_next_event(tp: TextPair) -> Option { }) } +/// CR 608.2k: Lift `StaticDefinition.affected: ParentTarget` → +/// `TriggeringSource` on every `Effect::GenericEffect` in a `WhenNextEvent` +/// delayed trigger's body, recursing through chained `sub_ability` links. +/// +/// A `WhenNextEvent` ("when you next cast a this turn, that spell +/// gains ") has no chosen/parent target — the just-cast spell object +/// IS the referent of "that spell". The effect parser lowers the subject- +/// position "that spell" anaphor to `ParentTarget` (oracle_target.rs) because +/// trigger context is not threaded through it. Left as `ParentTarget`, the +/// runtime registers the transient grant against `chain_tracked_set_id`, which +/// is empty for a delayed trigger, so the grant silently never lands (#5337, +/// Solar Array / Lux Artillery's when-next form). Rebinding to +/// `TriggeringSource` routes it through `resolve_event_context_target` +/// (effect.rs:259) — the same path Lux Artillery's non-delayed "it gains +/// sunburst" already uses. Only `GenericEffect`-borne grants are affected; +/// other effect variants (e.g. a delayed `ChangeZone` on the cast spell) are +/// out of scope for this class and left untouched. +fn lift_generic_effect_parent_target_to_triggering_source_in_ability( + ability: &mut AbilityDefinition, +) { + let mut node = Some(ability); + while let Some(link) = node { + if let Effect::GenericEffect { + static_abilities, .. + } = link.effect.as_mut() + { + for static_def in static_abilities.iter_mut() { + if matches!(static_def.affected, Some(TargetFilter::ParentTarget)) { + static_def.affected = Some(TargetFilter::TriggeringSource); + } + } + } + node = link.sub_ability.as_deref_mut(); + } +} + /// CR 603.7: Parse a generic non-cast "when you next this turn, /// " one-shot delayed trigger. Delegates condition recognition to the /// shared trigger-condition parser (`parse_trigger_condition`) so the whole diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 581c9a6c63..431150c093 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -37272,6 +37272,48 @@ fn when_next_cast_spell_with_x_in_cost_parses() { assert!(matches!(&*effect.effect, Effect::Draw { .. })); } +/// #5337 gap 1 (CR 608.2k): in a "when you next cast this turn" delayed +/// grant, the subject-position "that spell" anaphor names the newly-cast spell +/// (the trigger's event source) — a `WhenNextEvent` delayed trigger has no +/// parent target, so `affected` must lower to `TriggeringSource`. Left as +/// `ParentTarget`, the runtime registers the grant against the (empty) +/// chain-tracked set and it silently never lands (Solar Array). +#[test] +fn when_next_cast_that_spell_grant_binds_triggering_source() { + use crate::types::ability::ContinuousModification; + use crate::types::keywords::Keyword; + let def = parse_effect_chain( + "When you next cast an artifact spell this turn, that spell gains sunburst.", + AbilityKind::Activated, + ); + let Effect::CreateDelayedTrigger { effect, .. } = &*def.effect else { + panic!("expected CreateDelayedTrigger, got {:?}", def.effect); + }; + let Effect::GenericEffect { + static_abilities, .. + } = &*effect.effect + else { + panic!("expected GenericEffect grant body, got {:?}", effect.effect); + }; + assert_eq!(static_abilities.len(), 1, "one grant static expected"); + let grant = &static_abilities[0]; + assert_eq!( + grant.affected, + Some(TargetFilter::TriggeringSource), + "the 'that spell' grant must bind the event source, not ParentTarget" + ); + assert!( + grant.modifications.iter().any(|m| matches!( + m, + ContinuousModification::AddKeyword { + keyword: Keyword::Sunburst + } + )), + "the grant must add Sunburst: {:?}", + grant.modifications + ); +} + /// CR 603.7 + CR 707.10: Magus Lucea Kane Psychic Stimulus delayed copy. #[test] fn magus_lucea_kane_psychic_stimulus_parses_delayed_copy() { diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index 81e2cc9a3d..d4cfa2e9cb 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -1710,22 +1710,47 @@ impl Keyword { ) } - /// CR 702.164b: Keywords whose multiple instances SUM their parameter values - /// into a single aggregate (e.g. a creature's total toxic value), rather than - /// collapsing identical instances. When such a keyword is granted on top of an - /// identical printed instance, BOTH must remain on the keyword list so the - /// aggregate reader counts every copy. Distinct from `instances_function_separately` - /// (which gates per-instance trigger installation — a different semantic axis). - /// Conservative/CR-driven: only Toxic sums today (CR 702.164b). Protection - /// (CR 702.16g), Ward, Annihilator, Afflict, Frenzy do NOT sum — they keep - /// deduping identical instances. Add any future "sum of all N" keyword here. + /// CR 702.164b + CR 702.44d: Keywords whose multiple instances must COEXIST on + /// the keyword list rather than collapse to one when an identical instance is + /// granted on top of a printed one. When such a keyword is granted on top of an + /// identical printed instance, BOTH must remain on the keyword list so each + /// instance's effect is realized separately. Two disjoint reasons a keyword + /// belongs here, both served by the same "don't dedup identical instances" + /// mechanic in the keyword-grant paths: + /// + /// - **Parameter-value summation** (CR 702.164b): the aggregate reader sums + /// each instance's parameter (a creature's total toxic value). Only Toxic + /// sums today. Protection (CR 702.16g), Ward, Annihilator, Afflict, Frenzy do + /// NOT sum — they keep deduping identical instances. + /// - **Instance-count multiplicity** (CR 702.44d + CR 702.54c): an as-enters + /// static ability where "each instance works separately" — Sunburst places + /// its as-enters counters once per instance (CR 702.44a), and Bloodthirst + /// likewise places its counters once per instance (CR 702.54a). A GRANTED + /// Sunburst ("that spell gains sunburst": Solar Array / Lux Artillery) or + /// GRANTED Bloodthirst ("it gains bloodthirst 3": Bloodlord of Vaasgoth) on + /// top of an identical printed one must coexist so both the printed + /// object-carried replacement AND the granted virtual replacement (counting + /// the base-subtracted surplus) fire. Without coexistence the layer-6 grant + /// dedups against the identical printed instance and the granted instance is + /// silently lost. (Bloodthirst carries a `BloodthirstValue`, so the + /// granted-count is per DISTINCT value — a granted `bloodthirst 3` and a + /// printed `bloodthirst 1` never dedup regardless.) + /// + /// Distinct from `instances_function_separately` (which gates per-instance + /// TRIGGER installation, and deliberately excludes Sunburst because its + /// multiplicity is realized by synthesis / the granted-replacement path, not by + /// the trigger installer — a different semantic axis). Add any future keyword + /// whose identical instances must coexist here. /// /// Out of scope (intentionally not gated by this predicate): cast-time spell /// keyword merge (`casting.rs` `upsert_keyword_by_kind`/`merge_spell_keyword` — /// Toxic is inert at cast time) and the layers `AddDynamicKeyword` arm - /// (`DynamicKeywordKind` is only Annihilator/Modular, never Toxic). - pub fn sums_across_instances(&self) -> bool { - matches!(self, Keyword::Toxic(_)) + /// (`DynamicKeywordKind` is only Annihilator/Modular, never Toxic/Sunburst). + pub fn instances_must_coexist(&self) -> bool { + matches!( + self, + Keyword::Toxic(_) | Keyword::Sunburst | Keyword::Bloodthirst(_) + ) } /// CR 613.7: When multiple effects grant the same single-authoritative-value @@ -1737,7 +1762,7 @@ impl Keyword { /// (CR 702.122/702.171, vehicle/mount crew-power) and Enchant (CR 702.5a, /// an Aura's current legal-attachment filter, reachable via /// `AddKeyword{Enchant(_)}` from `install_aura_continuous_effect`) are the - /// currently known members. Contrast `sums_across_instances` (Toxic, which + /// currently known members. Contrast `instances_must_coexist` (Toxic, which /// accumulates) and the default (Protection/Ward/Annihilator, which coexist /// as separate instances per CR 702.16g). pub fn overrides_same_kind_on_grant(&self) -> bool { diff --git a/crates/engine/tests/integration/granted_bloodthirst_5802.rs b/crates/engine/tests/integration/granted_bloodthirst_5802.rs new file mode 100644 index 0000000000..39a8f4d8bc --- /dev/null +++ b/crates/engine/tests/integration/granted_bloodthirst_5802.rs @@ -0,0 +1,241 @@ +//! #5802 review (matthewevans): the granted-as-enters-keyword virtual candidate +//! must generalize beyond Sunburst to Bloodthirst. +//! +//! Bloodlord of Vaasgoth's "Whenever you cast a Vampire creature spell, it gains +//! bloodthirst 3" grants Bloodthirst to a spell still ON THE STACK. Printed +//! Bloodthirst is synthesized into an object-carried ETB `ReplacementDefinition` +//! (`synthesize_bloodthirst`); a runtime grant adds only the keyword, so — exactly +//! like granted Sunburst (#5337) — the runtime must surface a VIRTUAL as-enters +//! counter replacement for the granted instance. +//! +//! Unlike Sunburst, fixed-N Bloodthirst is CONDITIONAL (CR 702.54a): the counters +//! are placed only if an opponent was dealt damage this turn. The virtual applier +//! honors that carried condition — the negative test below (no opponent damage → +//! ZERO counters) is the revert-canary for the condition handling. +//! +//! Oracle text is verbatim from Scryfall: +//! - Bloodlord of Vaasgoth: "Bloodthirst 3 (If an opponent was dealt damage this +//! turn, this creature enters with three +1/+1 counters on it.)\nFlying\n +//! Whenever you cast a Vampire creature spell, it gains bloodthirst 3." +//! +//! CR references (verified against docs/MagicCompRules.txt): +//! - CR 702.54a: Bloodthirst N — if an opponent was dealt damage this turn, the +//! creature enters with N +1/+1 counters. +//! - CR 702.54c: multiple instances of Bloodthirst each work separately. +//! - CR 613.1 + CR 400.7a: a keyword granted to a spell on the stack applies to +//! the permanent that spell becomes. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::TargetRef; +use engine::types::card_type::CoreType; +use engine::types::counter::CounterType; +use engine::types::game_state::DamageRecord; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::{BloodthirstValue, Keyword}; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const BLOODLORD_ORACLE: &str = "Bloodthirst 3 (If an opponent was dealt damage this turn, this creature enters with three +1/+1 counters on it.)\nFlying\nWhenever you cast a Vampire creature spell, it gains bloodthirst 3."; + +/// Float `count` units of `ty` into P0's mana pool so the cast is funded. +fn add_mana(runner: &mut GameRunner, ty: ManaType, count: usize) { + for _ in 0..count { + let unit = ManaUnit::new(ty, ObjectId(0), false, vec![]); + runner.state_mut().players[0].mana_pool.add(unit); + } +} + +fn counters_of(runner: &GameRunner, id: ObjectId, ct: &CounterType) -> u32 { + runner + .state() + .objects + .get(&id) + .and_then(|o| o.counters.get(ct)) + .copied() + .unwrap_or(0) +} + +/// Record noncombat damage to P0's opponent (P1) earlier this turn so the +/// Bloodthirst condition (CR 702.54a: "an opponent was dealt damage this turn") +/// is TRUE. CR 702.54a doesn't care about the source, so any source id works. +fn deal_damage_to_opponent(runner: &mut GameRunner) { + runner + .state_mut() + .damage_dealt_this_turn + .push_back(DamageRecord { + source_id: ObjectId(999), + source_controller: P0, + target: TargetRef::Player(P1), + target_controller: P1, + amount: 1, + is_combat: false, + ..Default::default() + }); +} + +/// Grant `bloodthirst 3` to `spell` the way Bloodlord's trigger would — a +/// transient continuous `AddKeyword` on the stack object, then rebuild the runner +/// so the layer pass materializes the grant onto the object's live keyword set. +/// This mirrors the `stack_object_keyword_grants` idiom (a `TriggeringSource` +/// keyword grant to a spell on the stack) and exercises the SAME virtual-candidate +/// surfacing the full Bloodlord trigger would reach at entry. +fn grant_bloodthirst_via_transient(runner: &mut GameRunner, spell: ObjectId, value: u32) { + use engine::types::ability::{ContinuousModification, Duration, TargetFilter}; + + runner.state_mut().add_transient_continuous_effect( + spell, + P0, + Duration::UntilEndOfTurn, + TargetFilter::SpecificObject { id: spell }, + vec![ContinuousModification::AddKeyword { + keyword: Keyword::Bloodthirst(BloodthirstValue::Fixed(value)), + }], + None, + ); +} + +/// Set up a Bloodlord on the battlefield (its trigger will grant Bloodthirst to a +/// cast Vampire creature) and a Vampire creature spell in hand (funded but not yet +/// cast). Returns `(runner, bloodlord, spell)`. +fn bloodlord_board() -> (GameRunner, ObjectId, ObjectId) { + board_with(true) +} + +/// Set up a Vampire creature spell in hand WITHOUT any Bloodlord on the +/// battlefield, so ONLY an explicit transient grant places counters (no +/// interfering real trigger). Returns `(runner, spell)`. +fn plain_vampire_board() -> (GameRunner, ObjectId) { + let (runner, _sink, spell) = board_with(false); + (runner, spell) +} + +fn board_with(include_bloodlord: bool) -> (GameRunner, ObjectId, ObjectId) { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + // A vanilla permanent keeps `sink` populated when Bloodlord is absent so the + // return shape is uniform; its plain-vanilla text grants nothing. + let sink = if include_bloodlord { + scenario + .add_creature_from_oracle(P0, "Bloodlord of Vaasgoth", 4, 4, BLOODLORD_ORACLE) + .id() + } else { + scenario.add_creature(P0, "Vanilla Bear", 2, 2).id() + }; + + let spell = scenario + .add_creature_to_hand_from_oracle(P0, "Test Vampire", 2, 2, "") + .with_subtypes(vec!["Vampire"]) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + // The cast spell must be a Vampire CREATURE for Bloodlord's `valid_card`. + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Creature]; + if !obj.card_types.subtypes.iter().any(|s| s == "Vampire") { + obj.card_types.subtypes.push("Vampire".to_string()); + } + obj.base_card_types = obj.card_types.clone(); + } + (runner, sink, spell) +} + +/// PRIMARY revert-canary for the Bloodthirst generalization: a spell GRANTED +/// bloodthirst 3 (transient, no interfering trigger), cast with an opponent +/// already dealt damage this turn, must enter with 3 +1/+1 counters via the +/// virtual granted-Bloodthirst candidate. +#[test] +fn granted_bloodthirst_with_opponent_damage_enters_with_three_p1p1() { + let (mut runner, spell) = plain_vampire_board(); + + // CR 702.54a: an opponent WAS dealt damage this turn — condition holds. + deal_damage_to_opponent(&mut runner); + grant_bloodthirst_via_transient(&mut runner, spell, 3); + add_mana(&mut runner, ManaType::Black, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + // Reach-guard: the granted spell actually resolved onto the battlefield, so + // the entry replacement pipeline WAS consulted for its ZoneChange. + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the granted-bloodthirst spell must have resolved onto the battlefield" + ); + // PRIMARY revert-failing assertion: reverting the granted-Bloodthirst virtual + // candidate (or the shared applier) makes this 0. + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 3, + "granted bloodthirst 3 with an opponent damaged this turn must place 3 +1/+1 counters" + ); +} + +/// CONDITIONAL negative — the revert-canary for the applier's condition handling +/// (CR 702.54a). Same grant, but NO opponent was dealt damage this turn, so the +/// carried `condition` is unmet and ZERO counters are placed. +#[test] +fn granted_bloodthirst_without_opponent_damage_places_no_counters() { + let (mut runner, spell) = plain_vampire_board(); + + // CR 702.54a: NO opponent damage recorded — the condition is FALSE. + assert!( + runner.state().damage_dealt_this_turn.is_empty(), + "precondition: no damage this turn" + ); + grant_bloodthirst_via_transient(&mut runner, spell, 3); + add_mana(&mut runner, ManaType::Black, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + // Reach-guard: the spell resolved (so the granted-Bloodthirst candidate WAS + // consulted for its battlefield entry) — the zero below is the condition + // gating, not a short-circuit before the candidate ran. + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the spell must resolve so the granted-bloodthirst candidate is consulted" + ); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 0, + "granted bloodthirst 3 with NO opponent damaged this turn must place ZERO counters (CR 702.54a)" + ); +} + +/// End-to-end through the REAL Bloodlord trigger: cast a Vampire creature spell +/// while Bloodlord is on the battlefield. Its "Whenever you cast a Vampire +/// creature spell, it gains bloodthirst 3" trigger grants Bloodthirst to the spell +/// on the stack; with an opponent damaged this turn it enters with 3 counters. +#[test] +fn bloodlord_trigger_grants_bloodthirst_end_to_end() { + let (mut runner, bloodlord, spell) = bloodlord_board(); + let _ = bloodlord; + + deal_damage_to_opponent(&mut runner); + add_mana(&mut runner, ManaType::Black, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the cast Vampire creature must resolve onto the battlefield" + ); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 3, + "Bloodlord's granted bloodthirst 3 (opponent damaged) must place 3 +1/+1 counters end-to-end" + ); + let _ = PlayerId(0); +} diff --git a/crates/engine/tests/integration/granted_sunburst_5337.rs b/crates/engine/tests/integration/granted_sunburst_5337.rs new file mode 100644 index 0000000000..6161208069 --- /dev/null +++ b/crates/engine/tests/integration/granted_sunburst_5337.rs @@ -0,0 +1,703 @@ +//! Issue #5337 — GRANTED sunburst must place as-enters counters. +//! +//! Two coordinated gaps are exercised end-to-end through the real cast / +//! activation / trigger / replacement pipeline (no shape assertions): +//! +//! Gap 1 (parser): Solar Array's "When you next cast an artifact spell this +//! turn, THAT SPELL gains sunburst" is a `WhenNextEvent` delayed grant. The +//! subject-position "that spell" anaphor must bind `TriggeringSource` (the newly +//! cast spell / event source) rather than `ParentTarget` — a delayed trigger has +//! no parent target, so a `ParentTarget` grant registers against the empty +//! chain-tracked set and silently never lands (CR 608.2k). +//! +//! Gap 2 (runtime): a spell GRANTED sunburst carries the keyword but no +//! object-carried ETB replacement (only PRINTED sunburst is synthesized into +//! `replacement_definitions`). The runtime must surface a virtual as-enters +//! counter replacement for the granted instance so the permanent enters with a +//! counter per color of mana spent (CR 702.44a/b/d). +//! +//! Oracle texts are verbatim from Scryfall: +//! - Solar Array: "{T}: Add one mana of any color. When you next cast an +//! artifact spell this turn, that spell gains sunburst. (...)" +//! - Lux Artillery: "Whenever you cast an artifact creature spell, it gains +//! sunburst. (...)\n..." +//! +//! CR references (verified against docs/MagicCompRules.txt): +//! - CR 702.44a: sunburst — enters with a +1/+1 (creature) or charge (otherwise) +//! counter for each color of mana spent to cast it. +//! - CR 702.44b: counts colors of mana spent, from the stack as a resolving spell. +//! - CR 702.44d: multiple instances of sunburst each work separately. +//! - CR 608.2k: a "that spell" anaphor in a delayed trigger names the event source. +//! - CR 616.1: replacement-effect ordering (Doubling Season interplay). + +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::types::card_type::CoreType; +use engine::types::counter::CounterType; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const SOLAR_ARRAY_ORACLE: &str = "{T}: Add one mana of any color. When you next cast an artifact spell this turn, that spell gains sunburst. (If it's a creature, it enters with a +1/+1 counter on it for each color of mana spent to cast it. Otherwise, it enters with that many charge counters on it.)"; + +const LUX_ARTILLERY_ORACLE: &str = "Whenever you cast an artifact creature spell, it gains sunburst. (It enters with a +1/+1 counter on it for each color of mana spent to cast it.)"; + +/// Turn a scenario "creature" permanent into a pure noncreature artifact and +/// clear its P/T so the 0/0 stub isn't destroyed as an SBA before use. +fn make_artifact(runner: &mut GameRunner, id: ObjectId) { + let obj = runner.state_mut().objects.get_mut(&id).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.base_card_types = obj.card_types.clone(); + obj.power = None; + obj.toughness = None; + obj.base_power = None; + obj.base_toughness = None; +} + +/// Float `count` units of `ty` into P0's mana pool. +fn add_mana(runner: &mut GameRunner, ty: ManaType, count: usize) { + for _ in 0..count { + let unit = ManaUnit::new(ty, ObjectId(0), false, vec![]); + runner.state_mut().players[0].mana_pool.add(unit); + } +} + +fn counters_of(runner: &GameRunner, id: ObjectId, ct: &CounterType) -> u32 { + runner + .state() + .objects + .get(&id) + .and_then(|o| o.counters.get(ct)) + .copied() + .unwrap_or(0) +} + +fn charge() -> CounterType { + CounterType::Generic("charge".to_string()) +} + +/// Index of Solar Array's `{T}` mana ability. +fn mana_ability_index(runner: &GameRunner, id: ObjectId) -> usize { + runner + .state() + .objects + .get(&id) + .unwrap() + .abilities + .iter() + .position(engine::game::mana_abilities::is_mana_ability) + .expect("Solar Array has a mana ability") +} + +/// Activate Solar Array's mana ability so the `WhenNextEvent` delayed grant is +/// created. Drives the "{T}: Add one mana of any color" color prompt manually +/// (`WaitingFor::ChooseManaColor` → `GameAction::ChooseManaColor`, the +/// brigid_mana_ability idiom), then CLEARS the pool so the produced unit cannot +/// leak into the cast — each test funds the cast with an explicit floated pool +/// so the colors-of-mana-spent mix stays test-controlled (CR 702.44b). +fn arm_solar_array(runner: &mut GameRunner, solar: ObjectId) { + use engine::types::actions::GameAction; + use engine::types::game_state::{ManaChoice, WaitingFor}; + + let idx = mana_ability_index(runner, solar); + runner + .act(GameAction::ActivateAbility { + source_id: solar, + ability_index: idx, + }) + .expect("activating Solar Array's mana ability must succeed"); + if matches!( + runner.state().waiting_for, + WaitingFor::ChooseManaColor { .. } + ) { + runner + .act(GameAction::ChooseManaColor { + choice: ManaChoice::SingleColor(ManaType::White), + count: 1, + }) + .expect("submitting the any-color choice must succeed"); + } + runner.state_mut().players[0].mana_pool.clear(); +} + +/// PRIMARY end-to-end revert-canary for BOTH gaps: Solar Array grants sunburst +/// to a cast artifact CREATURE spell; paying three distinct colors, it must +/// enter with three +1/+1 counters. +#[test] +fn solar_array_grants_sunburst_creature_three_colors_enters_with_three_p1p1() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + + let spell = scenario + .add_creature_to_hand_from_oracle(P0, "Test Golem", 0, 0, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ + ManaCostShard::White, + ManaCostShard::Blue, + ManaCostShard::Black, + ], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + // The cast spell must be an artifact creature. + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact, CoreType::Creature]; + obj.base_card_types = obj.card_types.clone(); + } + + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::White, 1); + add_mana(&mut runner, ManaType::Blue, 1); + add_mana(&mut runner, ManaType::Black, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + // PRIMARY revert-failing assertion: reverting EITHER gap makes this 0. + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 3, + "granted-sunburst artifact creature cast for 3 colors must enter with 3 +1/+1 counters" + ); + // Reach-guard: the spell actually resolved onto the battlefield. + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the granted spell must have resolved onto the battlefield" + ); +} + +/// Solar Array grants sunburst to a NONCREATURE artifact → charge counters. +#[test] +fn solar_array_grants_sunburst_noncreature_two_colors_enters_with_two_charge() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Test Relic", false, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::White, ManaCostShard::Green], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + // The cast spell is a noncreature artifact. + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.base_card_types = obj.card_types.clone(); + } + + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::White, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!( + counters_of(&runner_after, spell, &charge()), + 2, + "granted-sunburst noncreature artifact cast for 2 colors must enter with 2 charge counters" + ); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 0, + "a noncreature granted-sunburst permanent must not place +1/+1 counters (CR 702.44a)" + ); + assert_eq!(outcome.zone_of(spell), Zone::Battlefield); +} + +/// CR 702.44a revert-canary for the PRINTED-vs-LIVE core-type branch. +/// +/// Sunburst reads "if this object is entering as a creature, IGNORING ANY +/// TYPE-CHANGING EFFECTS that would affect it". This spell is a PRINTED +/// noncreature artifact whose LIVE card types include Creature while it is on the +/// stack — exactly the state a type-changing effect leaves behind (Layer-6 type +/// effects do reach off-battlefield objects via `remote_type_layer_recipients`, +/// and the layer pass re-seeds live characteristics only for battlefield objects, +/// so the divergence survives to the entry-replacement pipeline). +/// +/// Branching on the LIVE types yields +1/+1 counters; the rule mandates the +/// PRINTED types, so charge counters are correct. Every other fixture in this +/// file sets `base_card_types == card_types`, so this is the only test that +/// exercises the divergent arm — without it the branch is unverified. +#[test] +fn granted_sunburst_ignores_type_changing_effect_and_branches_on_printed_types() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Test Relic", false, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::White, ManaCostShard::Green], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + // PRINTED (characteristic-defining): a noncreature artifact. + obj.base_card_types.core_types = vec![CoreType::Artifact]; + // LIVE: a type-changing effect has made it an artifact creature. CR 702.44a + // orders sunburst to ignore precisely this. + obj.card_types.core_types = vec![CoreType::Artifact, CoreType::Creature]; + } + + // Non-vacuity guard: the printed/live divergence this test turns on is really + // present at cast time. If a future change re-seeds stack objects from their + // printed types, this fires instead of the test silently going green. + { + let obj = runner.state().objects.get(&spell).unwrap(); + assert!( + obj.card_types.core_types.contains(&CoreType::Creature), + "fixture precondition: the LIVE types must include Creature" + ); + assert!( + !obj.base_card_types.core_types.contains(&CoreType::Creature), + "fixture precondition: the PRINTED types must NOT include Creature" + ); + } + + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::White, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + // Reach-guard: the spell actually entered, so the counter assertions below + // cannot pass vacuously on a spell that never resolved. + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the granted spell must have resolved onto the battlefield" + ); + // REVERT-FAILING: branching on the live `card_types` makes this 0 (and the + // +1/+1 assertion below 2). + assert_eq!( + counters_of(&runner_after, spell, &charge()), + 2, + "CR 702.44a: sunburst ignores type-changing effects, so a PRINTED noncreature \ + artifact must enter with charge counters even while a type-changing effect \ + makes it a creature" + ); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 0, + "CR 702.44a: the LIVE creature type must not redirect sunburst to +1/+1 counters" + ); +} + +/// Lux Artillery grants sunburst via a NON-delayed trigger ("it gains +/// sunburst"). Revert-canary for gap 2 alone (its trigger already lowers to +/// `TriggeringSource`, so gap 1's parser lift is not exercised). +#[test] +fn lux_artillery_grants_sunburst_two_colors_enters_with_two_p1p1() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let lux = scenario + .add_creature_from_oracle(P0, "Lux Artillery", 0, 0, LUX_ARTILLERY_ORACLE) + .id(); + + let spell = scenario + .add_creature_to_hand_from_oracle(P0, "Test Automaton", 0, 0, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Red, ManaCostShard::Green], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, lux); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact, CoreType::Creature]; + obj.base_card_types = obj.card_types.clone(); + } + + add_mana(&mut runner, ManaType::Red, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 2, + "Lux Artillery's granted sunburst on a 2-color cast must place 2 +1/+1 counters" + ); + assert_eq!(outcome.zone_of(spell), Zone::Battlefield); +} + +/// Negative (CR 702.44b): a granted-sunburst artifact cast paying ZERO colored +/// mana (all generic/colorless) must enter with NO counters. +#[test] +fn solar_array_granted_sunburst_zero_colors_places_no_counters() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Test Colorless Relic", false, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![], + generic: 2, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.base_card_types = obj.card_types.clone(); + } + + arm_solar_array(&mut runner, solar); + // Two colorless units fund the {2} generic cost — no colored mana spent. + add_mana(&mut runner, ManaType::Colorless, 2); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + // Reach-guard: the spell resolved (so the replacement pipeline WAS consulted + // for its battlefield entry), yet zero colors were spent. + assert_eq!( + outcome.zone_of(spell), + Zone::Battlefield, + "the colorless-cast granted-sunburst permanent must have resolved" + ); + assert_eq!( + counters_of(&runner_after, spell, &charge()), + 0, + "zero colors of mana spent means zero charge counters (CR 702.44b)" + ); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 0, + "zero colors of mana spent means zero +1/+1 counters" + ); + let _ = ManaColor::White; // keep the ManaColor import honest across cfgs +} + +/// Printed-sunburst control (CR 702.44a/b): the pre-synthesized object-carried +/// replacement path is untouched by the granted-instance virtual candidate — +/// a PRINTED-sunburst noncreature artifact cast for three colors still enters +/// with three charge counters. +#[test] +fn printed_sunburst_control_three_colors_enters_with_three_charge() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + // Explicit keyword hint: the bare "Sunburst (reminder)" line needs the + // MTGJSON-style keyword name for the scenario's keyword-line detection, + // which feeds `synthesize_all` (the printed ETB-replacement synthesis). + let spell = { + let mut b = scenario.add_spell_to_hand(P0, "Printed Relic", false); + b.from_oracle_text_with_keywords( + &["Sunburst"], + "Sunburst (This enters with a charge counter on it for each color of mana spent to cast it.)", + ); + b.with_mana_cost(ManaCost::Cost { + shards: vec![ + ManaCostShard::White, + ManaCostShard::Blue, + ManaCostShard::Black, + ], + generic: 0, + }) + .id() + }; + + let mut runner = scenario.build(); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.base_card_types = obj.card_types.clone(); + } + + add_mana(&mut runner, ManaType::White, 1); + add_mana(&mut runner, ManaType::Blue, 1); + add_mana(&mut runner, ManaType::Black, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!(outcome.zone_of(spell), Zone::Battlefield); + assert_eq!( + counters_of(&runner_after, spell, &charge()), + 3, + "printed sunburst cast for 3 colors must enter with 3 charge counters (control)" + ); +} + +/// CR 702.44d: "If an object has multiple instances of sunburst, each one works +/// separately." A PRINTED-sunburst artifact that is ALSO granted sunburst by +/// Solar Array must place its as-enters counters TWICE — once via the +/// object-carried printed replacement, once via the granted-instance virtual +/// candidate (which counts only the granted surplus, so nothing is lost or +/// double-counted on either side). +#[test] +fn printed_plus_granted_sunburst_each_apply_separately() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + // Explicit keyword hint — see the printed-control test above. + let spell = { + let mut b = scenario.add_spell_to_hand(P0, "Printed Relic", false); + b.from_oracle_text_with_keywords( + &["Sunburst"], + "Sunburst (This enters with a charge counter on it for each color of mana spent to cast it.)", + ); + b.with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::White, ManaCostShard::Green], + generic: 0, + }) + .id() + }; + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.base_card_types = obj.card_types.clone(); + } + + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::White, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!(outcome.zone_of(spell), Zone::Battlefield); + assert_eq!( + counters_of(&runner_after, spell, &charge()), + 4, + "printed + granted sunburst on a 2-color cast must each place 2 charge counters (CR 702.44d: 2+2=4)" + ); +} + +/// CR 616.1: the granted-instance virtual candidate participates in the normal +/// replacement-ordering pipeline — a Doubling Season-class AddCounter doubler +/// doubles the granted sunburst's placement (2 colors → 2 counters → 4). +#[test] +fn granted_sunburst_participates_in_counter_doubling() { + use engine::types::ability::QuantityModification; + use engine::types::replacements::ReplacementEvent; + use engine::types::ReplacementDefinition; + + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + let doubler = scenario.add_creature(P0, "Doubling Season", 0, 3).id(); + let spell = scenario + .add_creature_to_hand_from_oracle(P0, "Test Golem", 0, 0, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Red, ManaCostShard::Green], + generic: 0, + }) + .id(); + + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact, CoreType::Creature]; + obj.base_card_types = obj.card_types.clone(); + } + // CR 614.1a: a Doubling Season-class counter-doubling replacement. + { + let repl = ReplacementDefinition::new(ReplacementEvent::AddCounter) + .quantity_modification(QuantityModification::DOUBLE); + runner + .state_mut() + .objects + .get_mut(&doubler) + .unwrap() + .replacement_definitions + .push(repl); + } + + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::Red, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let outcome = runner.cast(spell).resolve(); + let runner_after = GameRunner::from_state(outcome.state().clone()); + + assert_eq!(outcome.zone_of(spell), Zone::Battlefield); + assert_eq!( + counters_of(&runner_after, spell, &CounterType::Plus1Plus1), + 4, + "granted sunburst (2 colors) under a counter doubler must enter with 4 +1/+1 counters (CR 616.1)" + ); +} + +/// #5802 review (CR 616.1e): the granted-sunburst virtual candidate is a +/// counter-payload WRITE (`Writes { Count, Additive }`), not `Disjoint` — when a +/// same-event Count writer co-fires on the entering spell's ZoneChange, the +/// affected controller's ordering choice MUST surface. Reverting the +/// classification to `Disjoint` suppresses the prompt and this test fails. +/// +/// Both legal orderings are driven end-to-end. NOTE on outcomes: in the current +/// engine the two orders converge (2 counters each) because a bare +/// `quantity_modification` on a `Moved`-keyed definition has no ZoneChange +/// counter-payload applier yet — the functioning Doubling Season path scales the +/// downstream AddCounter placement instead (covered by +/// `granted_sunburst_participates_in_counter_doubling`, which asserts 4). The +/// assertions below pin (a) the prompt surfacing with both candidates, (b) both +/// orders being drivable to a clean entry, and (c) the granted payload surviving +/// either order — so if a ZoneChange payload applier lands later, only the +/// counter totals need updating (4 for sunburst-first, 2 for writer-first), not +/// the ordering machinery. +fn drive_sunburst_vs_count_writer_ordering(pick_sunburst_first: bool) -> u32 { + use engine::types::ability::QuantityModification; + use engine::types::actions::GameAction; + use engine::types::game_state::WaitingFor; + use engine::types::replacements::ReplacementEvent; + use engine::types::ReplacementDefinition; + + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + let solar = scenario + .add_creature_from_oracle(P0, "Solar Array", 0, 0, SOLAR_ARRAY_ORACLE) + .id(); + let writer = scenario.add_creature(P0, "Entry Count Writer", 0, 3).id(); + let spell = scenario + .add_creature_to_hand_from_oracle(P0, "Test Golem", 0, 0, "") + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Red, ManaCostShard::Green], + generic: 0, + }) + .id(); + let mut runner = scenario.build(); + make_artifact(&mut runner, solar); + { + let obj = runner.state_mut().objects.get_mut(&spell).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact, CoreType::Creature]; + obj.base_card_types = obj.card_types.clone(); + } + // A same-event Count writer on the entering spell's ZoneChange (the + // Moved-keyed quantity-modification shape from the reclassified pair). + { + let repl = ReplacementDefinition::new(ReplacementEvent::Moved) + .quantity_modification(QuantityModification::DOUBLE); + runner + .state_mut() + .objects + .get_mut(&writer) + .unwrap() + .replacement_definitions + .push(repl); + } + arm_solar_array(&mut runner, solar); + add_mana(&mut runner, ManaType::Red, 1); + add_mana(&mut runner, ManaType::Green, 1); + + let commit = runner.cast(spell).commit(); + let mut r2 = GameRunner::from_state(commit.state().clone()); + let mut saw_ordering_prompt = false; + for _ in 0..30 { + match r2.state().waiting_for.clone() { + WaitingFor::Priority { .. } => { + if r2.act(GameAction::PassPriority).is_err() { + break; + } + } + WaitingFor::ReplacementChoice { candidates, .. } => { + // CR 616.1e revert-canary: BOTH candidates must be offered + // together — a `Disjoint` classification auto-applies the + // sunburst appender and never surfaces this prompt. + if candidates.len() == 2 { + saw_ordering_prompt = true; + let sunburst_idx = candidates + .iter() + .position(|c| c.description.contains("Sunburst")) + .expect("sunburst candidate must be listed"); + let writer_idx = 1 - sunburst_idx; + let idx = if pick_sunburst_first { + sunburst_idx + } else { + writer_idx + }; + r2.act(GameAction::ChooseReplacement { index: idx }) + .expect("ordering choice must be accepted"); + } else { + r2.act(GameAction::ChooseReplacement { index: 0 }) + .expect("remaining replacement choice must be accepted"); + } + } + other => panic!("unexpected waiting state during entry: {other:?}"), + } + let done = { + let st = r2.state(); + st.objects + .get(&spell) + .is_some_and(|o| o.zone == Zone::Battlefield) + }; + if done { + break; + } + } + assert!( + saw_ordering_prompt, + "the CR 616.1e ordering prompt must surface for the co-firing counter-payload writers" + ); + let o = r2.state().objects.get(&spell).unwrap(); + assert_eq!(o.zone, Zone::Battlefield, "the spell must finish entering"); + o.counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0) +} + +#[test] +fn granted_sunburst_ordering_choice_sunburst_first() { + let counters = drive_sunburst_vs_count_writer_ordering(true); + assert_eq!( + counters, 2, + "sunburst-first: the granted payload (2 colors) must survive the ordering pass" + ); +} + +#[test] +fn granted_sunburst_ordering_choice_count_writer_first() { + let counters = drive_sunburst_vs_count_writer_ordering(false); + assert_eq!( + counters, 2, + "writer-first: the granted payload must still be appended after the writer applies" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 621d6f14a3..48b660f32e 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -198,6 +198,8 @@ mod gollum_scheming_guide_card_predicate_guess; mod good_king_mog_xii_chapter_iv_588; mod gran_gran_integration; mod granted_alt_cost_hand_keyword; +mod granted_bloodthirst_5802; +mod granted_sunburst_5337; mod greater_good_activation; mod green_suns_zenith_regression; mod griffin_rider_conditional_self_buff;