From a169a0e425e4d07bb659c125cdb66f5c0a0085f6 Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:58:46 +1000 Subject: [PATCH 1/6] fix(engine): honor dynamically granted Sunburst at battlefield entry (Solar Array #5337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "That spell gains sunburst" (Solar Array) and "it gains sunburst" (Lux Artillery) placed ZERO as-enters counters. Three stacked defects, fixed per layer: 1. Parser — the grant never landed (Solar Array). In a "when you next cast a this turn" delayed trigger, the subject-position "that spell" anaphor lowered to `affected: ParentTarget`; a `WhenNextEvent` delayed trigger has no parent target, so `register_transient_effect` bound the grant against the empty chain-tracked set and silently dropped it. A new lift (`lift_generic_effect_parent_target_to_triggering_source_in_ability`, mirroring the oracle_trigger.rs lift family) rebinds GenericEffect grants in a WhenNextEvent body to `TriggeringSource` — the just-cast spell is the event source (CR 608.2k). 2. Engine — a landed grant still placed no counters (the class bug, also breaking Lux Artillery). Printed sunburst is realized as an object-carried ETB `ReplacementDefinition` pre-synthesized from the card face's PRINTED keywords (`synthesize_sunburst`); a granted keyword adds no replacement and nothing consulted live keywords at entry. A granted-instance VIRTUAL replacement candidate now surfaces in `find_applicable_replacements` (alongside the shield/finality-counter virtual candidates), built from the same shared `sunburst_replacement_definition` authority the printed synthesis uses, so it participates in normal CR 616 replacement ordering (Doubling Season doubles it). Granted instances are counted as EFFECTIVE minus BASE keywords via `effective_off_zone_keywords` — the entering spell is still on the STACK when its entry pipeline runs, and a granted keyword exists only as a continuous effect at that moment (the materialized keyword list is empty), so the off-zone authority is the only correct read. The base subtraction keeps printed instances on their carried definitions — printed + granted apply separately, exactly CR 702.44d. 3. Engine — cast-trigger resolution wiped the color provenance. Resolving an intervening cast trigger (Lux Artillery's own grant trigger) cleared `colors_spent_to_cast` on objects still on the STACK, erasing the color count before the granted spell entered. The clear now preserves live cast provenance for stack objects (CR 601.2h), mirroring how cast_from_zone is preserved. Supporting changes: `Keyword` instance-coexistence predicate extended so a granted Sunburst survives next to an identical printed instance (CR 702.44d "each one works separately"; previously only Toxic's summation kept duplicates); `synthesize_sunburst`'s per-instance definition extracted into the shared `sunburst_replacement_definition` builder used by both the printed synthesis and the virtual candidate. Tests (integration, real activate/cast/trigger/replacement pipeline, verbatim Oracle texts): - Solar Array: creature cast for 3 colors -> 3 +1/+1; noncreature for 2 -> 2 charge; zero colored mana -> 0 counters (CR 702.44b). - Lux Artillery: 2 colors -> 2 +1/+1 (gap 2+3 canary). - Printed control: 3 colors -> 3 charge (carried-definition path untouched). - Printed + granted: 2 colors -> 4 charge (CR 702.44d separateness). - Counter doubling: granted sunburst under an AddCounter doubler -> 4 (CR 616 ordering through the virtual candidate). - Parser shape: the delayed grant lowers `affected: TriggeringSource` with the Sunburst AddKeyword (gap 1 canary). Full lib suite: 16549 passed, 0 failed. Integration: 3038 passed, 0 failed. Fixes #5337. Co-Authored-By: Claude Fable 5 --- crates/engine/src/database/synthesis.rs | 84 +-- crates/engine/src/game/replacement.rs | 200 ++++++++ crates/engine/src/game/triggers.rs | 20 +- crates/engine/src/parser/oracle_effect/mod.rs | 48 +- .../engine/src/parser/oracle_effect/tests.rs | 43 ++ crates/engine/src/types/keywords.rs | 40 +- .../integration/granted_sunburst_5337.rs | 480 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 8 files changed, 870 insertions(+), 46 deletions(-) create mode 100644 crates/engine/tests/integration/granted_sunburst_5337.rs diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index 89e61538ee..0ce519cfaf 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -7211,44 +7211,64 @@ 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_replacements` → `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) } } diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 074d0960d1..63ff0eb67f 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -68,6 +68,18 @@ 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. +const GRANTED_SUNBURST_INDEX: usize = usize::MAX - 7; /// 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 @@ -221,6 +233,50 @@ fn object_has_finality_counter(state: &GameState, object_id: ObjectId) -> bool { .is_some_and(|count| *count > 0) } +fn granted_sunburst_replacement_id(object_id: ObjectId) -> ReplacementId { + ReplacementId { + source: object_id, + index: GRANTED_SUNBURST_INDEX, + } +} + +fn is_granted_sunburst_replacement(rid: ReplacementId) -> bool { + rid.index == GRANTED_SUNBURST_INDEX +} + +/// CR 702.44d + CR 604.1: The number of GRANTED sunburst instances on `object_id` +/// — the object's EFFECTIVE sunburst count minus its printed (base) count. +/// +/// Printed sunburst is realized through object-carried `ReplacementDefinition`s +/// (`synthesize_sunburst`); only the granted instances need a virtual candidate, +/// so subtract `base_keywords` (mirrors `synthesize_granted_keyword_triggers`). +/// Printed-only sunburst returns 0 here — its counters come from the carried +/// definitions, never this path — which keeps printed + granted double-applying +/// per CR 702.44d. +/// +/// 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). +fn granted_sunburst_instances(state: &GameState, object_id: ObjectId) -> usize { + let Some(obj) = state.objects.get(&object_id) else { + return 0; + }; + let live = crate::game::off_zone_characteristics::effective_off_zone_keywords(state, object_id) + .iter() + .filter(|kw| matches!(kw, crate::types::keywords::Keyword::Sunburst)) + .count(); + let base = obj + .base_keywords + .iter() + .filter(|kw| matches!(kw, crate::types::keywords::Keyword::Sunburst)) + .count(); + live.saturating_sub(base) +} + fn compleated_life_paid(state: &GameState, object_id: ObjectId) -> Option { state.objects.get(&object_id).and_then(|obj| { (obj.phyrexian_life_paid > 0 @@ -328,6 +384,107 @@ fn apply_compleated_replacement( } } +/// CR 702.44a + CR 702.44d + CR 614.1c: Apply the granted-sunburst virtual ETB +/// replacement — fold the as-enters counters onto the entering spell's +/// `ZoneChange`, one placement per GRANTED sunburst instance. +/// +/// The per-instance counter shape is the single shared authority +/// (`sunburst_replacement_definition`) the printed-sunburst synthesizer also +/// builds, so a granted spell places exactly the same as-enters counters a +/// printed one would. The counter branch (+1/+1 vs charge) is chosen from the +/// entering object's current core types (CR 702.44a), and the per-color count +/// is resolved by `event_modifiers_for_ability` against the entering spell so +/// `QuantityRef::ManaSpentToCast { DistinctColors }` reads its own +/// `colors_spent_to_cast` (CR 601.2h) — identical to the printed path. +/// +/// One `enter_with_counters` entry is pushed per granted instance (CR 702.44d: +/// 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_sunburst_replacement( + state: &mut GameState, + event: ProposedEvent, + rid: ReplacementId, + events: &mut Vec, +) -> ProposedEvent { + let instances = granted_sunburst_instances(state, rid.source); + if instances == 0 { + return event; + } + // CR 702.44a: branch on the entering object's current core types. + let counter_type = state + .objects + .get(&rid.source) + .filter(|obj| obj.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); + // Resolve the per-color count once via the shared ETB-modifier extractor, + // threading the entering object as the source so the self-scoped + // `ManaSpentToCast` ref reads its own cast tally (CR 601.2h). + let modifiers = + event_modifiers_for_ability(definition.execute.as_deref(), state, rid.source, &event); + + let ProposedEvent::ZoneChange { + object_id, + from, + to, + cause, + attach_to, + enter_tapped, + mut enter_with_counters, + controller_override, + enter_transformed, + face_down_profile, + applied, + } = event + else { + return event; + }; + if object_id != rid.source { + // Rebuild the event unchanged if the ids diverged (defensive; the + // candidate is keyed on the entering object so this never fires). + return ProposedEvent::ZoneChange { + object_id, + from, + to, + cause, + attach_to, + enter_tapped, + enter_with_counters, + controller_override, + enter_transformed, + face_down_profile, + applied, + }; + } + // CR 702.44d: one placement per granted instance. + for _ in 0..instances { + enter_with_counters.extend(modifiers.etb_counters.iter().cloned()); + } + // 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(), + }); + ProposedEvent::ZoneChange { + object_id, + from, + to, + cause, + attach_to, + enter_tapped, + enter_with_counters, + controller_override, + enter_transformed, + face_down_profile, + applied, + } +} + /// CR 614.1: Replacement effects modify events as they would occur. #[derive(Debug, Clone, PartialEq)] pub enum ReplacementResult { @@ -851,6 +1008,11 @@ 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 is_granted_sunburst_replacement(rid) { + // CR 702.44a: mandatory ETB-counter replacement — only ever labeled in a + // CR 616.1 ordering prompt, never offered as an accept/decline choice. + return "Sunburst: enter with counters for colors of mana spent".to_string(); + } if is_finality_counter_replacement(rid) { return "Exile it instead".to_string(); } @@ -6117,6 +6279,30 @@ pub fn find_applicable_replacements( } } + // CR 702.44a + CR 702.44d + CR 614.1c: Granted sunburst — a spell GRANTED + // sunburst as it was cast ("that spell gains sunburst": Solar Array / Lux + // Artillery) carries the keyword in its live keyword set but no object-carried + // ETB replacement (only printed sunburst is synthesized into + // `replacement_definitions`). Surface one virtual ETB-counter candidate 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. A single reserved candidate covers all granted + // instances — its applier emits one counter placement per granted instance + // (CR 702.44d), 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 printed sunburst. + if let ProposedEvent::ZoneChange { + object_id, + to: Zone::Battlefield, + .. + } = event + { + let rid = granted_sunburst_replacement_id(*object_id); + if granted_sunburst_instances(state, *object_id) > 0 && !event.already_applied(&rid) { + 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 @@ -6823,6 +7009,12 @@ fn apply_single_replacement( return Ok(apply_compleated_replacement(state, proposed, rid, events)); } + if is_granted_sunburst_replacement(rid) { + return Ok(apply_granted_sunburst_replacement( + state, proposed, rid, events, + )); + } + if let Some(kind) = shield_counter_replacement_kind(rid) { return apply_shield_counter_replacement(state, proposed, rid, kind, events); } @@ -7525,6 +7717,14 @@ fn candidate_materiality( return CandidateMateriality::Unconditional; } + // CR 616.1 + CR 614.1c: granted sunburst only APPENDS to `enter_with_counters` + // (like a printed `PutCounter` ETB replacement, which resolves to `Disjoint` + // below), so it is order-independent against every other candidate — no CR + // 616.1 ordering prompt is forced. + if is_granted_sunburst_replacement(rid) { + return CandidateMateriality::Disjoint; + } + // 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/game/triggers.rs b/crates/engine/src/game/triggers.rs index abd5058ed0..e5da6d585a 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -3501,8 +3501,24 @@ fn clear_post_collection_transients(state: &mut GameState) { if !matches!(obj.zone, Zone::Battlefield | Zone::Stack) { obj.cast_from_zone = None; } - obj.mana_spent_to_cast = false; - obj.colors_spent_to_cast = crate::types::mana::ColoredManaCount::default(); + // CR 601.2h + CR 702.44b: `mana_spent_to_cast` / `colors_spent_to_cast` + // are live cast provenance for a spell still on the STACK. A spell's own + // as-enters ability that reads "mana spent to cast it" (sunburst, + // CR 702.44a, and the `ManaSpentToCast` quantity family) fires when the + // spell resolves onto the battlefield — which, for a GRANTED sunburst + // (Solar Array / Lux Artillery: "that spell/it gains sunburst"), happens + // AFTER the intervening cast-triggered grant resolves and this clear runs. + // Wiping it for stack objects erased the color count before the granted + // spell entered, so it placed zero counters (#5337). Preserve it while the + // object is on the stack, exactly as `cast_from_zone` is preserved above. + // Battlefield objects are still cleared here (the color breakdown is not a + // battlefield-resident fact; `mana_spent_to_cast_amount` — the historical + // total — is preserved elsewhere), and non-stack/non-battlefield objects + // (fizzled / bounced) are cleared as before. + if !matches!(obj.zone, Zone::Stack) { + obj.mana_spent_to_cast = false; + obj.colors_spent_to_cast = crate::types::mana::ColoredManaCount::default(); + } } } diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 8448dab799..ec8ebfca5e 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -1247,11 +1247,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) @@ -1294,6 +1304,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 f639ad8d21..472709ca7f 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -36615,6 +36615,49 @@ fn when_next_cast_spell_with_x_in_cost_parses() { } /// CR 603.7 + CR 707.10: Magus Lucea Kane Psychic Stimulus delayed copy. + +/// #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 + ); +} + #[test] fn magus_lucea_kane_psychic_stimulus_parses_delayed_copy() { use crate::types::ability::{ diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index ad6c1cab0a..8a917d5949 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -1708,22 +1708,40 @@ 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): a parameter-less as-enters + /// static ability where "each instance works separately" — Sunburst places + /// its as-enters counters once per instance (CR 702.44a). A GRANTED Sunburst + /// ("that spell gains sunburst": Solar Array / Lux Artillery) on top of a + /// printed one must coexist so both the printed object-carried replacement AND + /// the granted virtual replacement (`granted_sunburst_instances` counts the + /// base-subtracted surplus) fire. Without coexistence the layer-6 grant + /// dedups against the identical printed instance and the granted instance is + /// silently lost. + /// + /// 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). + /// (`DynamicKeywordKind` is only Annihilator/Modular, never Toxic/Sunburst). pub fn sums_across_instances(&self) -> bool { - matches!(self, Keyword::Toxic(_)) + matches!(self, Keyword::Toxic(_) | Keyword::Sunburst) } /// CR 613.7: When multiple effects grant the same single-authoritative-value 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..a6bfcbcbad --- /dev/null +++ b/crates/engine/tests/integration/granted_sunburst_5337.rs @@ -0,0 +1,480 @@ +//! 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); +} + +/// 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)" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 4d7c6c0c3e..4608591cba 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -165,6 +165,7 @@ 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_sunburst_5337; mod greater_good_activation; mod green_suns_zenith_regression; mod griffin_rider_conditional_self_buff; From c04ead0d55689e88c1e1d105284307db887caa22 Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:24:50 +1000 Subject: [PATCH 2/6] style: restore the Magus Lucea Kane doc comment to its function (clippy empty-line-after-doc) The #5337 parser-shape test was inserted between an existing doc comment and its function; reattach the comment. Co-Authored-By: Claude Fable 5 --- crates/engine/src/parser/oracle_effect/tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 472709ca7f..7e9e0d6468 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -36614,8 +36614,6 @@ fn when_next_cast_spell_with_x_in_cost_parses() { assert!(matches!(&*effect.effect, Effect::Draw { .. })); } -/// CR 603.7 + CR 707.10: Magus Lucea Kane Psychic Stimulus delayed copy. - /// #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 @@ -36658,6 +36656,7 @@ fn when_next_cast_that_spell_grant_binds_triggering_source() { ); } +/// CR 603.7 + CR 707.10: Magus Lucea Kane Psychic Stimulus delayed copy. #[test] fn magus_lucea_kane_psychic_stimulus_parses_delayed_copy() { use crate::types::ability::{ From 1cd34fa0d03cfa3d37319c26635b4ca63cd389f6 Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:22:14 +1000 Subject: [PATCH 3/6] fix(engine): classify granted sunburst as an additive counter-payload write (CR 616.1e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #5802: the virtual granted-sunburst candidate was classified `Disjoint` in `candidate_materiality`, but its applier appends to the same event counter payload a co-firing Count writer modifies — an append does not commute with a doubler ((0+N)*2 vs 0*2+N), so `Disjoint` silently suppressed the CR 616.1e affected-controller ordering choice. Reclassify as `Writes { field: Count, commute: Additive }`: two appenders still commute (no degenerate prompt), while an appender + any non-additive Count writer on one event now surfaces the ordering prompt. Integration regression (both fail on revert to `Disjoint` with "the CR 616.1e ordering prompt must surface"): granted sunburst + a same-event Moved-keyed `quantity_modification(DOUBLE)` writer co-fire on the entering spell's ZoneChange; the ordering prompt lists both candidates, and BOTH legal orderings are driven end-to-end to a clean entry with the granted payload intact. Empirical note recorded in the test doc: the two orderings currently 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 (asserted at 4 by `granted_sunburst_participates_in_counter_ doubling`). The reclassification pins the ordering machinery so that if a ZoneChange payload applier lands later, only the expected totals change (4 sunburst-first vs 2 writer-first), not the choice surface. Full lib: 16549 passed. Integration: 3040 passed. Clippy clean. Co-Authored-By: Claude Fable 5 --- crates/engine/src/game/replacement.rs | 15 +- .../integration/granted_sunburst_5337.rs | 136 ++++++++++++++++++ 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 63ff0eb67f..c7d1a69400 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -7717,12 +7717,17 @@ fn candidate_materiality( return CandidateMateriality::Unconditional; } - // CR 616.1 + CR 614.1c: granted sunburst only APPENDS to `enter_with_counters` - // (like a printed `PutCounter` ETB replacement, which resolves to `Disjoint` - // below), so it is order-independent against every other candidate — no CR - // 616.1 ordering prompt is forced. + // CR 616.1 + CR 614.1c: granted sunburst 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_sunburst_replacement(rid) { - return CandidateMateriality::Disjoint; + return CandidateMateriality::Writes { + field: EventField::Count, + commute: CommuteClass::Additive, + }; } // CR 614.10: the turn-scoped combat skip fully prevents the BeginPhase event, diff --git a/crates/engine/tests/integration/granted_sunburst_5337.rs b/crates/engine/tests/integration/granted_sunburst_5337.rs index a6bfcbcbad..314fe3eb65 100644 --- a/crates/engine/tests/integration/granted_sunburst_5337.rs +++ b/crates/engine/tests/integration/granted_sunburst_5337.rs @@ -478,3 +478,139 @@ fn granted_sunburst_participates_in_counter_doubling() { "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" + ); +} From af975c2c4bbc6955ee4a691578f03e1bc6572c69 Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:13:56 +1000 Subject: [PATCH 4/6] feat(engine): generalize the granted-ETB-keyword path to Bloodthirst (Bloodlord of Vaasgoth, #5802 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matthewevans's [MED] review: the virtual ETB replacement handled only granted Sunburst, leaving the equivalent granted Bloodthirst path (Bloodlord of Vaasgoth: "Whenever you cast a Vampire creature spell, it gains bloodthirst 3") incorrect — a new special case beside an equally-broken keyword. Generalize the machinery into a keyword-agnostic granted-ETB-keyword path covering both Sunburst and Bloodthirst (and any future as-enters keyword): - `GrantedEtbKeyword { Sunburst, Bloodthirst }` with per-keyword reserved virtual-candidate ids (`GRANTED_SUNBURST_INDEX = MAX-7`, `GRANTED_BLOODTHIRST_INDEX = MAX-8`) feeding a shared count/apply core; `rid.index` recovers the keyword. - `granted_keyword_etb_instances` factors out the EFFECTIVE-minus-BASE keyword count (via `effective_off_zone_keywords`, since the entering spell is still on the stack); `granted_sunburst_instances` delegates to it, `granted_bloodthirst_instances` counts per distinct `BloodthirstValue` (mirroring `synthesize_bloodthirst`, CR 702.54c). - `bloodthirst_replacement_definition` extracted in synthesis.rs (mirroring `sunburst_replacement_definition`); both the printed synthesizer and the virtual applier build per-instance definitions from the same authority. - `apply_granted_keyword_etb_replacement` is keyword-agnostic and honors each definition's carried `condition` via `evaluate_replacement_condition` — Bloodthirst fixed-N only places counters when an opponent was dealt damage this turn (CR 702.54a); Sunburst has `condition: None` and always applies. `granted_etb_keyword_candidate_applies` re-checks the condition at registration so a condition-unmet grant raises no spurious CR 616.1e prompt. - `find_applicable_replacements` registers both keywords on `ZoneChange`→Battlefield; materiality stays `Writes { Count, Additive }`. - `keywords.rs`: Bloodthirst added to the instance-coexistence predicate so a granted instance survives beside an identical printed one (CR 702.54c). Also addresses Gemini's nit: the applier no longer rebuilds `ProposedEvent::ZoneChange` field-by-field — it mutates `enter_with_counters` in place via `if let ProposedEvent::ZoneChange { enter_with_counters, .. } = &mut event`, immune to new event fields. Rebased onto current main (resolved the `usize::MAX - 6` index collision with the new commander-return virtual candidate; `GRANTED_SUNBURST_INDEX` moved to MAX-7). Tests (integration, real cast/trigger/replacement pipeline): granted Bloodthirst 3 with an opponent damaged this turn → 3 +1/+1 counters; NO opponent damaged → 0 counters (condition revert-canary); end-to-end via Bloodlord's real "it gains bloodthirst 3" trigger → 3 counters. All 9 existing sunburst tests stay green. Full lib: 16959 passed. Integration: 3349 passed. Clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/engine/src/database/synthesis.rs | 64 ++- crates/engine/src/game/replacement.rs | 457 +++++++++++++----- crates/engine/src/types/keywords.rs | 23 +- .../integration/granted_bloodthirst_5802.rs | 241 +++++++++ crates/engine/tests/integration/main.rs | 1 + 5 files changed, 625 insertions(+), 161 deletions(-) create mode 100644 crates/engine/tests/integration/granted_bloodthirst_5802.rs diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index 0ce519cfaf..0f43399b36 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -8129,27 +8129,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/replacement.rs b/crates/engine/src/game/replacement.rs index c7d1a69400..af87387042 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -79,7 +79,23 @@ const COMMANDER_HAND_OR_LIBRARY_RETURN_INDEX: usize = usize::MAX - 6; /// 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 @@ -233,50 +249,170 @@ fn object_has_finality_counter(state: &GameState, object_id: ObjectId) -> bool { .is_some_and(|count| *count > 0) } -fn granted_sunburst_replacement_id(object_id: ObjectId) -> ReplacementId { +/// 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: GRANTED_SUNBURST_INDEX, + index: kw.index(), } } -fn is_granted_sunburst_replacement(rid: ReplacementId) -> bool { - rid.index == GRANTED_SUNBURST_INDEX +fn is_granted_etb_keyword_replacement(rid: ReplacementId) -> bool { + GrantedEtbKeyword::from_index(rid.index).is_some() } -/// CR 702.44d + CR 604.1: The number of GRANTED sunburst instances on `object_id` -/// — the object's EFFECTIVE sunburst count minus its printed (base) count. +/// 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 sunburst is realized through object-carried `ReplacementDefinition`s -/// (`synthesize_sunburst`); only the granted instances need a virtual candidate, -/// so subtract `base_keywords` (mirrors `synthesize_granted_keyword_triggers`). -/// Printed-only sunburst returns 0 here — its counters come from the carried -/// definitions, never this path — which keeps printed + granted double-applying -/// per CR 702.44d. +/// 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). -fn granted_sunburst_instances(state: &GameState, object_id: ObjectId) -> usize { +/// 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, + predicate: impl Fn(&crate::types::keywords::Keyword) -> bool, +) -> usize { let Some(obj) = state.objects.get(&object_id) else { return 0; }; let live = crate::game::off_zone_characteristics::effective_off_zone_keywords(state, object_id) .iter() - .filter(|kw| matches!(kw, crate::types::keywords::Keyword::Sunburst)) - .count(); - let base = obj - .base_keywords - .iter() - .filter(|kw| matches!(kw, crate::types::keywords::Keyword::Sunburst)) + .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) -> usize { + granted_keyword_etb_instances(state, object_id, |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, +) -> Vec { + use crate::types::keywords::Keyword; + let Some(obj) = state.objects.get(&object_id) else { + return Vec::new(); + }; + let live = crate::game::off_zone_characteristics::effective_off_zone_keywords(state, object_id); + 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, +) -> 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) + .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 @@ -384,84 +520,139 @@ fn apply_compleated_replacement( } } -/// CR 702.44a + CR 702.44d + CR 614.1c: Apply the granted-sunburst virtual ETB -/// replacement — fold the as-enters counters onto the entering spell's -/// `ZoneChange`, one placement per GRANTED sunburst instance. +/// 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). /// -/// The per-instance counter shape is the single shared authority -/// (`sunburst_replacement_definition`) the printed-sunburst synthesizer also -/// builds, so a granted spell places exactly the same as-enters counters a -/// printed one would. The counter branch (+1/+1 vs charge) is chosen from the -/// entering object's current core types (CR 702.44a), and the per-color count -/// is resolved by `event_modifiers_for_ability` against the entering spell so -/// `QuantityRef::ManaSpentToCast { DistinctColors }` reads its own -/// `colors_spent_to_cast` (CR 601.2h) — identical to the printed path. +/// - Sunburst: N identical copies of `sunburst_replacement_definition`, branching +/// the counter type on the entering object's CURRENT 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). +fn granted_etb_replacement_definitions( + state: &GameState, + object_id: ObjectId, + kw: GrantedEtbKeyword, +) -> Vec { + match kw { + GrantedEtbKeyword::Sunburst => { + let instances = granted_sunburst_instances(state, object_id); + // CR 702.44a: branch on the entering object's current core types. + let counter_type = state + .objects + .get(&object_id) + .filter(|obj| obj.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) + .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` entry is pushed per granted instance (CR 702.44d: -/// 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_sunburst_replacement( +/// 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, - event: ProposedEvent, + mut event: ProposedEvent, rid: ReplacementId, events: &mut Vec, ) -> ProposedEvent { - let instances = granted_sunburst_instances(state, rid.source); - if instances == 0 { + 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 definitions = granted_etb_replacement_definitions(state, rid.source, kw); + if definitions.is_empty() { return event; } - // CR 702.44a: branch on the entering object's current core types. - let counter_type = state + + // 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) - .filter(|obj| obj.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); - // Resolve the per-color count once via the shared ETB-modifier extractor, - // threading the entering object as the source so the self-scoped - // `ManaSpentToCast` ref reads its own cast tally (CR 601.2h). - let modifiers = - event_modifiers_for_ability(definition.execute.as_deref(), state, rid.source, &event); - - let ProposedEvent::ZoneChange { - object_id, - from, - to, - cause, - attach_to, - enter_tapped, - mut enter_with_counters, - controller_override, - enter_transformed, - face_down_profile, - applied, - } = event - else { + .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; - }; - if object_id != rid.source { - // Rebuild the event unchanged if the ids diverged (defensive; the - // candidate is keyed on the entering object so this never fires). - return ProposedEvent::ZoneChange { - object_id, - from, - to, - cause, - attach_to, - enter_tapped, - enter_with_counters, - controller_override, - enter_transformed, - face_down_profile, - applied, - }; } - // CR 702.44d: one placement per granted instance. - for _ in 0..instances { - enter_with_counters.extend(modifiers.etb_counters.iter().cloned()); + + // 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 @@ -470,19 +661,7 @@ fn apply_granted_sunburst_replacement( source_id: rid.source, event_type: ReplacementEvent::Moved.to_string(), }); - ProposedEvent::ZoneChange { - object_id, - from, - to, - cause, - attach_to, - enter_tapped, - enter_with_counters, - controller_override, - enter_transformed, - face_down_profile, - applied, - } + event } /// CR 614.1: Replacement effects modify events as they would occur. @@ -1008,10 +1187,15 @@ 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 is_granted_sunburst_replacement(rid) { - // CR 702.44a: mandatory ETB-counter replacement — only ever labeled in a - // CR 616.1 ordering prompt, never offered as an accept/decline choice. - return "Sunburst: enter with counters for colors of mana spent".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(); @@ -6279,27 +6463,34 @@ pub fn find_applicable_replacements( } } - // CR 702.44a + CR 702.44d + CR 614.1c: Granted sunburst — a spell GRANTED - // sunburst as it was cast ("that spell gains sunburst": Solar Array / Lux - // Artillery) carries the keyword in its live keyword set but no object-carried - // ETB replacement (only printed sunburst is synthesized into - // `replacement_definitions`). Surface one virtual ETB-counter candidate 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. A single reserved candidate covers all granted - // instances — its applier emits one counter placement per granted instance - // (CR 702.44d), 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 printed sunburst. + // 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 { - let rid = granted_sunburst_replacement_id(*object_id); - if granted_sunburst_instances(state, *object_id) > 0 && !event.already_applied(&rid) { - candidates.push(rid); + for kw in [GrantedEtbKeyword::Sunburst, GrantedEtbKeyword::Bloodthirst] { + let rid = granted_etb_keyword_replacement_id(*object_id, kw); + if granted_etb_keyword_candidate_applies(state, *object_id, kw, event) + && !event.already_applied(&rid) + { + candidates.push(rid); + } } } @@ -7009,8 +7200,8 @@ fn apply_single_replacement( return Ok(apply_compleated_replacement(state, proposed, rid, events)); } - if is_granted_sunburst_replacement(rid) { - return Ok(apply_granted_sunburst_replacement( + if is_granted_etb_keyword_replacement(rid) { + return Ok(apply_granted_keyword_etb_replacement( state, proposed, rid, events, )); } @@ -7717,13 +7908,13 @@ fn candidate_materiality( return CandidateMateriality::Unconditional; } - // CR 616.1 + CR 614.1c: granted sunburst 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_sunburst_replacement(rid) { + // 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, diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index 8a917d5949..98d9cb7262 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -1720,15 +1720,19 @@ impl Keyword { /// 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): a parameter-less as-enters + /// - **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). A GRANTED Sunburst - /// ("that spell gains sunburst": Solar Array / Lux Artillery) on top of a - /// printed one must coexist so both the printed object-carried replacement AND - /// the granted virtual replacement (`granted_sunburst_instances` counts the - /// base-subtracted surplus) fire. Without coexistence the layer-6 grant + /// 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. + /// 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 @@ -1741,7 +1745,10 @@ impl Keyword { /// Toxic is inert at cast time) and the layers `AddDynamicKeyword` arm /// (`DynamicKeywordKind` is only Annihilator/Modular, never Toxic/Sunburst). pub fn sums_across_instances(&self) -> bool { - matches!(self, Keyword::Toxic(_) | Keyword::Sunburst) + matches!( + self, + Keyword::Toxic(_) | Keyword::Sunburst | Keyword::Bloodthirst(_) + ) } /// CR 613.7: When multiple effects grant the same single-authoritative-value 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/main.rs b/crates/engine/tests/integration/main.rs index 4608591cba..217c52b014 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -165,6 +165,7 @@ 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; From 44e2119c117733c8c3c75d30a088be9e1e2ee625 Mon Sep 17 00:00:00 2001 From: Matt Evans Date: Thu, 23 Jul 2026 15:12:47 -0700 Subject: [PATCH 5/6] fix(engine): fold the granted-Sunburst stack-provenance guard into main's cast-payment-stamp authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conflict resolution for the bring-current merge, isolated here so the judgement is reviewable on its own. This branch guarded `clear_post_collection_transients` so a spell still on the Stack kept `mana_spent_to_cast` / `colors_spent_to_cast`, because a GRANTED sunburst (Solar Array / Lux Artillery) enters AFTER the intervening cast-triggered grant resolves and that clear runs — wiping the color tally made it place zero counters (#5337). While this branch sat, main fixed the same underlying seam independently for issue #5943: `clear_post_collection_transients` now routes all five cast- payment stamps through `GameObject::clear_cast_payment_stamps()` and calls it ONLY for objects outside the Battlefield/Stack provenance zones. So `colors_spent_to_cast` — the field granted Sunburst actually reads, via `colors_spent_to_cast.distinct_colors()` — already survives on Stack objects under main's authority. The remaining unconditional line clears only the `mana_spent_to_cast` boolean, which main documents as a deliberate per-collection transient and which this feature does not read. Taking main's side therefore preserves this branch's behavior while keeping a single authority for payment-stamp lifetime, instead of layering a second, partly-redundant guard beside it. Verified, not assumed: with main's side taken, all 12 of this branch's own regressions pass — granted_sunburst_5337 (9) and granted_bloodthirst_5802 (3), including lux_artillery_grants_sunburst_two_colors_enters_with_two_p1p1 and solar_array_grants_sunburst_creature_three_colors_enters_with_three_p1p1, which are exactly the cases that fail if stack color provenance is wiped. Full `cargo test -p engine`: 21,496 passed, 0 failed. Co-authored-by: shin-core <153108882+shin-core@users.noreply.github.com> --- crates/engine/src/game/triggers.rs | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 2623290fbe..c5f13f0925 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -4735,24 +4735,7 @@ fn clear_post_collection_transients(state: &mut GameState) { // identity via reanimation. obj.clear_cast_payment_stamps(); } - // CR 601.2h + CR 702.44b: `mana_spent_to_cast` / `colors_spent_to_cast` - // are live cast provenance for a spell still on the STACK. A spell's own - // as-enters ability that reads "mana spent to cast it" (sunburst, - // CR 702.44a, and the `ManaSpentToCast` quantity family) fires when the - // spell resolves onto the battlefield — which, for a GRANTED sunburst - // (Solar Array / Lux Artillery: "that spell/it gains sunburst"), happens - // AFTER the intervening cast-triggered grant resolves and this clear runs. - // Wiping it for stack objects erased the color count before the granted - // spell entered, so it placed zero counters (#5337). Preserve it while the - // object is on the stack, exactly as `cast_from_zone` is preserved above. - // Battlefield objects are still cleared here (the color breakdown is not a - // battlefield-resident fact; `mana_spent_to_cast_amount` — the historical - // total — is preserved elsewhere), and non-stack/non-battlefield objects - // (fizzled / bounced) are cleared as before. - if !matches!(obj.zone, Zone::Stack) { - obj.mana_spent_to_cast = false; - obj.colors_spent_to_cast = crate::types::mana::ColoredManaCount::default(); - } + obj.mana_spent_to_cast = false; } } From f0b3f2270bf6aea23368f7b62512138760703be0 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 23 Jul 2026 15:50:41 -0700 Subject: [PATCH 6/6] fix(engine): branch granted Sunburst on printed types; cut the entry-gate keyword sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer review fixups on top of #5802. CR 702.44a rules defect: `granted_etb_replacement_definitions` branched the Sunburst counter type on `obj.card_types`, the LIVE layer result. The rule says sunburst applies "if this object is entering as a creature, IGNORING ANY TYPE-CHANGING EFFECTS that would affect it", and Layer-6 type effects do reach stack objects (`remote_type_layer_recipients`) — which is the zone this check runs in. Branch on `base_card_types` instead, the same printed `card_face.card_type` the printed synthesizer (`synthesize_sunburst`) uses, so the granted and printed paths agree. Adds the first fixture whose printed and live core types DIVERGE; all 12 existing fixtures set `base_card_types == card_types`, so this arm was previously unexercised and the defect was invisible. Perf on the `find_applicable_replacements` hot path (states are cloned and replayed constantly under AI search): the `ZoneChange`→Battlefield gate ran a full off-zone keyword sweep per keyword family, each one a whole-game continuous-effect collect plus ordering and per-effect filter evaluation, and the guard was ordered expensive-term-first. Test the cheap `already_applied` set lookup first, and resolve the live keyword list at most once (lazily) and thread it through the family helpers so one sweep serves every family. Also renames `Keyword::sums_across_instances` to `instances_must_coexist` — all three call sites implement "don't dedup identical instances" and none sums a parameter, matching the predicate's own doc — and repoints a dangling doc reference to `granted_sunburst_replacements`, a symbol that does not exist. Co-authored-by: shin-core <153108882+shin-core@users.noreply.github.com> --- crates/engine/src/database/synthesis.rs | 3 +- crates/engine/src/game/layers.rs | 4 +- .../src/game/off_zone_characteristics.rs | 2 +- crates/engine/src/game/replacement.rs | 77 +++++++++++----- crates/engine/src/types/keywords.rs | 4 +- .../integration/granted_sunburst_5337.rs | 87 +++++++++++++++++++ 6 files changed, 151 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index b474d2bea1..35d4b1265a 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -7231,7 +7231,8 @@ pub fn synthesize_sunburst(face: &mut CardFace) { /// 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_replacements` → `find_applicable_replacements`), which +/// (`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). 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 26715a32a5..33137c879d 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -314,22 +314,24 @@ fn is_granted_etb_keyword_replacement(rid: ReplacementId) -> bool { 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 = crate::game::off_zone_characteristics::effective_off_zone_keywords(state, object_id) - .iter() - .filter(|kw| predicate(kw)) - .count(); + 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) -> usize { - granted_keyword_etb_instances(state, object_id, |kw| { +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) }) } @@ -342,12 +344,13 @@ fn granted_sunburst_instances(state: &GameState, object_id: ObjectId) -> usize { 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 = crate::game::off_zone_characteristics::effective_off_zone_keywords(state, object_id); + let live = live_keywords; let distinct_values: Vec<_> = live .iter() .filter_map(|kw| match kw { @@ -393,13 +396,14 @@ fn granted_etb_keyword_candidate_applies( 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) + granted_etb_replacement_definitions(state, object_id, kw, live_keywords) .iter() .any(|definition| match &definition.condition { Some(cond) => evaluate_replacement_condition( @@ -527,33 +531,49 @@ fn apply_compleated_replacement( /// 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 CURRENT core types (CR 702.44a). +/// 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); - // CR 702.44a: branch on the entering object's current core types. + 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.card_types.core_types.contains(&CoreType::Creature)) + .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) - .iter() - .map(crate::database::synthesis::bloodthirst_replacement_definition) - .collect(), + GrantedEtbKeyword::Bloodthirst => { + granted_bloodthirst_instances(state, object_id, live_keywords) + .iter() + .map(crate::database::synthesis::bloodthirst_replacement_definition) + .collect() + } } } @@ -597,7 +617,9 @@ fn apply_granted_keyword_etb_replacement( return event; } - let definitions = granted_etb_replacement_definitions(state, rid.source, kw); + 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; } @@ -6774,11 +6796,26 @@ pub fn find_applicable_replacements( .. } = 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 granted_etb_keyword_candidate_applies(state, *object_id, kw, event) - && !event.already_applied(&rid) - { + 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); } } diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index fdfb1e09a2..d4cfa2e9cb 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -1746,7 +1746,7 @@ impl Keyword { /// 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/Sunburst). - pub fn sums_across_instances(&self) -> bool { + pub fn instances_must_coexist(&self) -> bool { matches!( self, Keyword::Toxic(_) | Keyword::Sunburst | Keyword::Bloodthirst(_) @@ -1762,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_sunburst_5337.rs b/crates/engine/tests/integration/granted_sunburst_5337.rs index 314fe3eb65..6161208069 100644 --- a/crates/engine/tests/integration/granted_sunburst_5337.rs +++ b/crates/engine/tests/integration/granted_sunburst_5337.rs @@ -222,6 +222,93 @@ fn solar_array_grants_sunburst_noncreature_two_colors_enters_with_two_charge() { 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).