Skip to content
Merged
149 changes: 97 additions & 52 deletions crates/engine/src/database/synthesis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7217,44 +7217,65 @@ pub fn synthesize_sunburst(face: &mut CardFace) {
.filter(|r| is_sunburst_etb_replacement(r, &counter_type))
.count();

let counter_phrase = match &counter_type {
for _ in existing..instances {
face.replacements
.push(sunburst_replacement_definition(&counter_type));
}
}

/// CR 702.44a + CR 702.44d + CR 601.2h: the single per-instance Sunburst ETB
/// replacement definition — one `Moved`→Battlefield replacement on `SelfRef`
/// whose execute places one counter of `counter_type` per distinct color of
/// mana spent to cast the object.
///
/// The single authority for building a Sunburst replacement, shared by:
/// - build-time synthesis (`synthesize_sunburst`) for PRINTED Sunburst, and
/// - the runtime granted-keyword replacement path
/// (`granted_sunburst_instances` → `granted_etb_replacement_definitions` →
/// `find_applicable_replacements`), which
/// surfaces one virtual candidate per *granted* Sunburst instance so a grant
/// ("that spell gains sunburst": Solar Array / Lux Artillery) also places
/// counters at entry (#5337).
///
/// Because both callers build identical definitions, printed + granted
/// instances each yield a distinct candidate and apply separately, exactly as
/// CR 702.44d ("If an object has multiple instances of sunburst, each one works
/// separately") requires.
pub(crate) fn sunburst_replacement_definition(counter_type: &CounterType) -> ReplacementDefinition {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
}
}

Expand Down Expand Up @@ -8115,27 +8136,51 @@ pub fn synthesize_bloodthirst(face: &mut CardFace) {
if existing >= needed {
continue;
}
let etb_counters = AbilityDefinition::new(
AbilityKind::Spell,
Effect::PutCounter {
counter_type: CounterType::Plus1Plus1,
count: bloodthirst_counter_quantity(value),
target: TargetFilter::SelfRef,
},
)
.description(bloodthirst_execute_description(value));
face.replacements
.push(bloodthirst_replacement_definition(value));
}
}

let replacement = ReplacementDefinition {
event: ReplacementEvent::Moved,
execute: Some(Box::new(etb_counters)),
valid_card: Some(TargetFilter::SelfRef),
condition: bloodthirst_condition(value),
// CR 614.1c: battlefield-entry-scoped (departure gate).
destination_zone: Some(Zone::Battlefield),
description: Some(bloodthirst_replacement_description(value)),
..ReplacementDefinition::new(ReplacementEvent::Moved)
};
face.replacements.push(replacement);
/// CR 702.54a + CR 702.54b + CR 702.54c: the single per-instance Bloodthirst ETB
/// replacement definition — one `Moved`→Battlefield replacement on `SelfRef`
/// whose execute places `bloodthirst_counter_quantity(value)` +1/+1 counters,
/// gated by `bloodthirst_condition(value)` (the fixed-N form is conditional on an
/// opponent having been dealt damage this turn; the X form is unconditional and
/// its count reads the damage total directly).
///
/// The single authority for building a Bloodthirst replacement, shared by:
/// - build-time synthesis (`synthesize_bloodthirst`) for PRINTED Bloodthirst, and
/// - the runtime granted-keyword replacement path
/// (`granted_bloodthirst_instances` → `find_applicable_replacements`), which
/// surfaces one virtual candidate per *granted* Bloodthirst instance so a grant
/// ("it gains bloodthirst 3": Bloodlord of Vaasgoth) also places counters at
/// entry (mirrors `sunburst_replacement_definition`, #5802).
///
/// Because both callers build identical definitions, printed + granted instances
/// each yield a distinct candidate and apply separately per CR 702.54c ("each
/// instance works separately").
pub(crate) fn bloodthirst_replacement_definition(
value: &BloodthirstValue,
) -> ReplacementDefinition {
let etb_counters = AbilityDefinition::new(
AbilityKind::Spell,
Effect::PutCounter {
counter_type: CounterType::Plus1Plus1,
count: bloodthirst_counter_quantity(value),
target: TargetFilter::SelfRef,
},
)
.description(bloodthirst_execute_description(value));

ReplacementDefinition {
event: ReplacementEvent::Moved,
execute: Some(Box::new(etb_counters)),
valid_card: Some(TargetFilter::SelfRef),
condition: bloodthirst_condition(value),
// CR 614.1c: battlefield-entry-scoped (departure gate).
destination_zone: Some(Zone::Battlefield),
description: Some(bloodthirst_replacement_description(value)),
..ReplacementDefinition::new(ReplacementEvent::Moved)
}
}

Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/game/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/game/off_zone_characteristics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading
Loading