diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 6ac90301e8..50dbf659ac 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -12,7 +12,7 @@ use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityKind, CardPlayMode, CardTypeSetSource, ControllerRef, CopyRetargetPermission, CostPaidObjectSnapshot, EachDamageRecipient, Effect, EffectError, EffectKind, EffectOutcomeSignal, EffectScope, FilterProp, OpponentMayScope, PlayerFilter, - PlayerScope, PtValue, QuantityExpr, QuantityRef, RepeatContinuation, ResolvedAbility, + PlayerScope, QuantityExpr, QuantityRef, RepeatContinuation, ResolvedAbility, RevealUntilDisposition, SacrificeCost, SacrificeRequirement, SharedQuality, SharedQualityRelation, SiblingCondition, SubAbilityLink, TapStateChange, TargetChoiceTiming, TargetFilter, TargetRef, ThisWayCause, @@ -4386,47 +4386,36 @@ fn ability_or_branch_references_tracked_set(ability: &ResolvedAbility) -> bool { /// class (GainLife, DealDamage, Token, Mill, Draw, PutCounter, GrantCastingPermission, …) /// without enumerating variants. fn effect_references_tracked_set(effect: &Effect) -> bool { - // Quantity positions — walk every QuantityExpr field on the effect. - let quantity_hits_tracked = |qty: &QuantityExpr| quantity_expr_references_tracked_set(qty); - let has_quantity_hit = match effect { - Effect::DealDamage { amount, .. } => quantity_hits_tracked(amount), - Effect::DamageAll { amount, .. } => quantity_hits_tracked(amount), - Effect::DamageEachPlayer { amount, .. } => quantity_hits_tracked(amount), - Effect::Draw { count, .. } => quantity_hits_tracked(count), - Effect::Mill { count, .. } => quantity_hits_tracked(count), - Effect::Scry { count, .. } => quantity_hits_tracked(count), - Effect::ArrangePlanarDeckTop { count, keep_on_top } => { - quantity_hits_tracked(count) || quantity_hits_tracked(keep_on_top) - } - Effect::Dig { count, .. } => quantity_hits_tracked(count), - Effect::Surveil { count, .. } => quantity_hits_tracked(count), - Effect::GainLife { amount, .. } => quantity_hits_tracked(amount), - Effect::LoseLife { amount, .. } => quantity_hits_tracked(amount), - Effect::ChangeSpeed { amount, .. } => quantity_hits_tracked(amount), - Effect::PutCounter { count, .. } => quantity_hits_tracked(count), - Effect::PutCounterAll { count, .. } => quantity_hits_tracked(count), - // CR 608.2c + CR 701.21a: an `ExileTop` whose count reduces the chain - // tracked set is a CONSUMER of that set, so the preceding producer must - // publish it. Kylox, Visionary Inventor ("sacrifice any number of other - // creatures, then exile the top X cards of your library, where X is - // their total power") is exactly this shape: without this arm - // `next_sub_needs_tracked_set` returned false, the sacrifice never - // published its affected ids, and the `TrackedSetAggregate` reduced an - // empty set to 0 — a fully-supported-looking card that exiles nothing. - Effect::ExileTop { count, .. } => quantity_hits_tracked(count), - Effect::Incubate { count } => quantity_hits_tracked(count), - Effect::Token { - count, - power, - toughness, - .. - } => { - quantity_hits_tracked(count) - || pt_value_references_tracked_set(power) - || pt_value_references_tracked_set(toughness) - } - _ => false, - }; + // Quantity positions — `Effect::for_each_quantity_expr` is the exhaustive + // authority over EVERY `QuantityExpr` an effect carries, including the + // secondary slots that `count_expr()` (the primary count/amount accessor) + // does not expose: `Token::enter_with_counters` entry-counter quantities, + // `Dig::keep_count_expr`, dynamic `PtValue::Quantity` power/toughness, etc. + // + // Any of those slots reducing the chain tracked set makes the effect a + // CONSUMER of that set, so the preceding producer must publish it. + // Concrete instances of this class: + // - CR 608.2c + CR 701.21a: Kylox, Visionary Inventor ("sacrifice any + // number of other creatures, then exile the top X cards of your library, + // where X is their total power") — `ExileTop::count`. Without the + // consumer classification, `next_sub_needs_tracked_set` returned false, + // the sacrifice never published its affected ids, and the + // `TrackedSetAggregate` reduced an empty set to 0 — a fully-supported- + // looking card that exiles nothing. + // - CR 608.2c + CR 701.9a (discard) + CR 701.21a (sacrifice): Malfegor + // ("each opponent sacrifices a creature of their choice for each card + // discarded this way", issue #5991) — `Sacrifice::count`. Same failure + // shape: the discard never published, so `FilteredTrackedSetSize + // { caused_by: Discarded }` resolved to 0 and no sacrifice happened. + // - CR 608.2c + CR 122.6: a token entering "with a +1/+1 counter for each + // card this way" — `Token::enter_with_counters`, a secondary + // quantity slot no primary-count shortcut can see. + // (Same class as the `repeat_for` check in + // `ability_or_branch_references_tracked_set` — Seasoned Pyromancer, #740.) + let mut has_quantity_hit = false; + effect.for_each_quantity_expr(&mut |qty| { + has_quantity_hit |= quantity_expr_references_tracked_set(qty); + }); if has_quantity_hit { return true; } @@ -4530,13 +4519,6 @@ fn effect_references_tracked_set(effect: &Effect) -> bool { false } -fn pt_value_references_tracked_set(value: &PtValue) -> bool { - match value { - PtValue::Fixed(_) | PtValue::Variable(_) => false, - PtValue::Quantity(expr) => quantity_expr_references_tracked_set(expr), - } -} - fn quantity_expr_references_tracked_set(qty: &QuantityExpr) -> bool { match qty { QuantityExpr::Fixed { .. } => false, @@ -12325,6 +12307,72 @@ mod tests { ); } + /// CR 608.2c + CR 122.6: a token with FIXED count and FIXED P/T whose + /// `enter_with_counters` quantity is `TrackedSetSize` is still a tracked-set + /// CONSUMER — the entry-counter slot is a secondary quantity position that + /// the primary `count_expr()` accessor does not expose. This is the exact + /// matcher-boundary gap from the #5991 review: with the shortcut + /// classification, the preceding producer never published its set and the + /// token entered with zero counters. + /// + /// Revert discriminator: replacing the `for_each_quantity_expr` walk in + /// `effect_references_tracked_set` with a `count`/`power`/`toughness`-only + /// inspection makes this assertion fail. + #[test] + fn token_tracked_entry_counter_marks_ability_as_referencing_tracked_set() { + let ability = ResolvedAbility::new( + Effect::Token { + name: "Zombie".to_string(), + power: PtValue::Fixed(2), + toughness: PtValue::Fixed(2), + types: vec!["Creature".to_string(), "Zombie".to_string()], + colors: vec![ManaColor::Black], + keywords: vec![], + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to: None, + enters_attacking: false, + supertypes: vec![], + static_abilities: vec![], + enter_with_counters: vec![( + crate::types::counter::CounterType::Plus1Plus1, + QuantityExpr::Ref { + qty: QuantityRef::TrackedSetSize, + }, + )], + }, + vec![], + ObjectId(1), + PlayerId(0), + ); + + assert!( + ability_or_branch_references_tracked_set(&ability), + "a TrackedSetSize entry-counter quantity must publish the chain tracked set" + ); + } + + /// CR 608.2c: a continuation that exiles a face-down pile must receive the + /// preceding instruction's tracked set when its top-library count is dynamic. + /// `ExileFaceDownPile::count` is carried by the exhaustive quantity visitor, + /// rather than by a one-off predicate arm. + #[test] + fn exile_face_down_pile_tracked_count_marks_effect_as_referencing_tracked_set() { + let effect = Effect::ExileFaceDownPile { + object: TargetFilter::SelfRef, + player: TargetFilter::Controller, + count: QuantityExpr::Ref { + qty: QuantityRef::TrackedSetSize, + }, + }; + + assert!( + effect_references_tracked_set(&effect), + "a tracked face-down-pile count must make the continuation consume the chain tracked set" + ); + } + /// CR 118.1 + CR 603.12a: the repeated-optional-payment driver pays each /// iteration in line and reads the failure flag immediately, so it must admit /// ONLY a pure, static mana `PayCost` (Hawkeye's `{1}`). A PayCost whose cost diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index ada81d472a..f03fe3bf1c 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -1103,6 +1103,23 @@ pub enum DieRollModifier { Subtract { value: QuantityExpr }, } +impl DieRollModifier { + /// Visits every `QuantityExpr` carried by this modifier. Composed into + /// `Effect::for_each_quantity_expr` (the exhaustive authority over every + /// quantity an effect resolves in the current chain): the modifier's + /// quantity is resolved live during `Effect::RollDie` resolution + /// (`game/effects/roll_die.rs`), so it must be visible to callers such as + /// the tracked-set consumer classifier. + /// + /// Exhaustive match — no wildcard arm — so a newly added variant must be + /// explicitly classified here. + pub fn for_each_quantity_expr<'a>(&'a self, f: &mut dyn FnMut(&'a QuantityExpr)) { + match self { + DieRollModifier::Add { value } | DieRollModifier::Subtract { value } => f(value), + } + } +} + impl std::str::FromStr for Parity { type Err = (); fn from_str(s: &str) -> Result { @@ -2038,6 +2055,45 @@ pub enum ManaProduction { TriggerEventManaType, } +impl ManaProduction { + /// Visits every `QuantityExpr` carried by this production descriptor. + /// Composed into `Effect::for_each_quantity_expr` (the exhaustive + /// authority over every quantity an effect resolves in the current + /// chain): each dynamic-count variant's `count` is resolved live during + /// `Effect::Mana` resolution (`resolve_count` in `game/effects/mana.rs`), + /// so those slots must be visible to callers such as the tracked-set + /// consumer classifier — "add {C} for each card discarded this way" + /// makes the mana effect a consumer of the preceding producer's set. + /// + /// Exhaustive match — no wildcard arm — so a newly added variant must be + /// explicitly classified here rather than silently reporting no + /// quantities. + pub fn for_each_quantity_expr<'a>(&'a self, f: &mut dyn FnMut(&'a QuantityExpr)) { + match self { + // --- Dynamic-count productions: `count` resolves at effect + // --- resolution time. + ManaProduction::Colorless { count } + | ManaProduction::AnyOneColor { count, .. } + | ManaProduction::AnyCombination { count, .. } + | ManaProduction::ChosenColor { count, .. } + | ManaProduction::OpponentLandColors { count } + | ManaProduction::AnyCombinationOfObjectColors { count, .. } + | ManaProduction::AnyTypeProduceableBy { count, .. } + | ManaProduction::AnyInCommandersColorIdentity { count, .. } + | ManaProduction::AnyOneColorAmongPermanents { count, .. } => f(count), + // --- Quantity-free productions: fixed color lists, fixed `u32` + // --- counts (`Mixed::colorless_count`), or amounts derived + // --- entirely from board/trigger state with no `QuantityExpr`. + ManaProduction::Fixed { .. } + | ManaProduction::Mixed { .. } + | ManaProduction::ChoiceAmongExiledColors { .. } + | ManaProduction::ChoiceAmongCombinations { .. } + | ManaProduction::DistinctColorsAmongPermanents { .. } + | ManaProduction::TriggerEventManaType => {} + } + } +} + /// CR 607.2a + CR 406.6 + CR 610.3: Which exile-link relation a mana ability reads /// when computing legal colors. Typed enum — never a bool — so future extensions /// (e.g. links to a different host than `~` itself) slot in cleanly. @@ -8477,6 +8533,70 @@ pub enum CostCategory { } impl AbilityCost { + /// Visits every `QuantityExpr` carried by this cost, recursing through + /// the compositional forms (`Composite`, `OneOf`, `PerCounter`) and into + /// the embedded effect of `EffectCost` (whose body resolves on the source + /// as part of paying the cost, so its quantities are live in the current + /// chain). Composed into `Effect::for_each_quantity_expr` (the exhaustive + /// authority over every quantity an effect resolves in the current + /// chain): a resolution-time `Effect::PayCost` resolves each of these + /// dynamic values when the cost is paid + /// (`costs::pay_ability_cost_for_resolution`), so they must be visible to + /// callers such as the tracked-set consumer classifier — "pay 1 life for + /// each card discarded this way" makes the payment a consumer of the + /// preceding producer's set. + /// + /// Exhaustive match — no wildcard arm — so a newly added variant must be + /// explicitly classified here rather than silently reporting no + /// quantities. + pub fn for_each_quantity_expr<'a>(&'a self, f: &mut dyn FnMut(&'a QuantityExpr)) { + match self { + // --- Direct `QuantityExpr` carriers --- + AbilityCost::ManaDynamic { quantity } => f(quantity), + AbilityCost::PayLife { amount } + | AbilityCost::PayEnergy { amount } + | AbilityCost::PaySpeed { amount } => f(amount), + AbilityCost::Discard { count, .. } => f(count), + // --- Recursive forms: every nested cost's quantities are part of + // --- paying THIS cost, so recurse. + AbilityCost::Composite { costs } | AbilityCost::OneOf { costs } => { + for cost in costs { + cost.for_each_quantity_expr(f); + } + } + AbilityCost::PerCounter { base, .. } => base.for_each_quantity_expr(f), + // CR 118.3: the cost's effect body resolves on the source before + // the ability's own effect — its quantity slots are live. + AbilityCost::EffectCost { effect } => effect.for_each_quantity_expr(f), + // --- Quantity-free costs: static mana pips, fixed `u32`/`i32` + // --- counts and aggregate thresholds, or purely structural + // --- payments with no `QuantityExpr` field. + AbilityCost::Mana { .. } + | AbilityCost::Tap + | AbilityCost::Untap + | AbilityCost::Loyalty { .. } + | AbilityCost::Sacrifice(_) + | AbilityCost::Exile { .. } + | AbilityCost::ExileMaterials { .. } + | AbilityCost::CollectEvidence { .. } + | AbilityCost::ExileWithAggregate { .. } + | AbilityCost::TapCreatures { .. } + | AbilityCost::RemoveCounter { .. } + | AbilityCost::ReturnToHand { .. } + | AbilityCost::Unattach + | AbilityCost::UnattachFrom { .. } + | AbilityCost::Mill { .. } + | AbilityCost::Exert + | AbilityCost::Blight { .. } + | AbilityCost::Reveal { .. } + | AbilityCost::Behold { .. } + | AbilityCost::Waterbend { .. } + | AbilityCost::NinjutsuFamily { .. } + | AbilityCost::KeywordCostOfCastSpell { .. } + | AbilityCost::Unimplemented { .. } => {} + } + } + /// CR 605.3a + CR 106.12 + CR 107.6: True iff every component of this cost is /// conclusively decided by the non-simulating mana-ability cheap gate /// (`mana_ability_ready_without_simulation`) — i.e. the cost is built solely @@ -14677,6 +14797,627 @@ impl Effect { } } + /// Visits every `QuantityExpr` carried by this effect — + /// including the secondary quantity slots that `count_expr()` (the + /// PRIMARY count/amount accessor) intentionally does not expose, such as + /// `Token::enter_with_counters` entry-counter quantities, + /// `Dig::keep_count_expr`, `PutSticker::max_ticket_cost`, dynamic + /// `PtValue::Quantity` power/toughness, and `ChangeZone`'s + /// (conditional) entry counters — and recurses through variants that + /// embed another `Effect`/`ResolvedAbility` (`CreateDrawReplacement`, + /// `CreatePlaneswalkReplacement`, `EpicCopy`). + /// + /// Nested domain types that carry their own quantities are walked + /// through composed exhaustive sub-visitors (each a no-wildcard match on + /// its own type): `ManaProduction::for_each_quantity_expr` for + /// `Effect::Mana`'s dynamic production counts, + /// `AbilityCost::for_each_quantity_expr` for `Effect::PayCost`'s dynamic + /// cost values (recursing through `Composite`/`OneOf`/`PerCounter` and + /// into `EffectCost`'s embedded effect), and + /// `DieRollModifier::for_each_quantity_expr` for `Effect::RollDie`'s + /// roll modifier. Smaller nested carriers are walked inline with the + /// same no-wildcard discipline: `LibraryPosition`'s dynamic slots + /// (`ChangeZoneAll`/`PutAtLibraryPosition`/`Conjure`/`Counter`'s + /// countered-spell destination), `UntilCondition::CumulativeThreshold` + /// (`ExileFromTopUntil`), `GuessSubject::Proposition` (`OpponentGuess`), + /// `KeeperConstraint::ExactCount` (`ChooseAndSacrificeRest`), and each + /// `ConjureCard::count`. + /// + /// Deliberately NOT walked: quantities inside deferred definition + /// payloads (`AbilityDefinition` — including `RollDie::results` branch + /// bodies, which resolve through their own `resolve_ability_chain` + /// entry — `ContinuousModification`, `DamageModification`, + /// `CastingPermission`, …). Those are evaluated in + /// their own later resolution context, not the currently resolving + /// chain's; where such a payload consumes the CURRENT chain's tracked + /// set, that is signalled by an explicit flag instead + /// (`CreateDelayedTrigger { uses_tracked_set }`). + /// + /// Exhaustive match — no wildcard arm — so a newly added Effect variant + /// must be explicitly classified here rather than silently reporting no + /// quantities (same convention as `count_expr()`/`target_filter()`). + pub fn for_each_quantity_expr<'a>(&'a self, f: &mut dyn FnMut(&'a QuantityExpr)) { + fn visit_pt<'a>(value: &'a PtValue, f: &mut dyn FnMut(&'a QuantityExpr)) { + match value { + PtValue::Fixed(_) | PtValue::Variable(_) => {} + PtValue::Quantity(expr) => f(expr), + } + } + // CR 401.7: dynamic library slots (`BeneathTop::depth`, + // `RandomWithinTop::n`) are resolved at effect-resolution time by the + // library-insertion authority; the fixed slots carry no quantity. + fn visit_library_position<'a>( + position: &'a LibraryPosition, + f: &mut dyn FnMut(&'a QuantityExpr), + ) { + match position { + LibraryPosition::Top + | LibraryPosition::Bottom + | LibraryPosition::NthFromTop { .. } => {} + LibraryPosition::BeneathTop { depth } => f(depth), + LibraryPosition::RandomWithinTop { n } => f(n), + } + } + fn visit_resolved_ability<'a>( + ability: &'a ResolvedAbility, + f: &mut dyn FnMut(&'a QuantityExpr), + ) { + ability.effect.for_each_quantity_expr(f); + if let Some(repeat_for) = ability.repeat_for.as_ref() { + f(repeat_for); + } + if let Some(sub) = ability.sub_ability.as_deref() { + visit_resolved_ability(sub, f); + } + if let Some(else_branch) = ability.else_ability.as_deref() { + visit_resolved_ability(else_branch, f); + } + } + match self { + Effect::ChangeSpeed { amount, .. } => { + f(amount); + } + Effect::DealDamage { amount, .. } => { + f(amount); + } + Effect::EachSourceDealsDamage { amount, .. } => { + f(amount); + } + Effect::Draw { count, .. } => { + f(count); + } + Effect::Pump { + power, toughness, .. + } => { + visit_pt(power, f); + visit_pt(toughness, f); + } + Effect::Token { + power, + toughness, + count, + enter_with_counters, + .. + } => { + visit_pt(power, f); + visit_pt(toughness, f); + f(count); + for (_, q) in enter_with_counters { + f(q); + } + } + Effect::GainLife { amount, .. } => { + f(amount); + } + Effect::LoseLife { amount, .. } => { + f(amount); + } + Effect::RemoveCounter { count, .. } => { + f(count); + } + Effect::Sacrifice { count, .. } => { + f(count); + } + Effect::Mill { count, .. } => { + f(count); + } + Effect::Scry { count, .. } => { + f(count); + } + Effect::PumpAll { + power, toughness, .. + } => { + visit_pt(power, f); + visit_pt(toughness, f); + } + Effect::DamageAll { amount, .. } => { + f(amount); + } + Effect::DamageEachPlayer { amount, .. } => { + f(amount); + } + Effect::ChangeZone { + enter_with_counters, + conditional_enter_with_counters, + .. + } => { + for (_, q) in enter_with_counters { + f(q); + } + for (_, _, q) in conditional_enter_with_counters { + f(q); + } + } + Effect::ChangeZoneAll { + enter_with_counters, + library_position, + .. + } => { + for (_, q) in enter_with_counters { + f(q); + } + if let Some(position) = library_position { + visit_library_position(position, f); + } + } + Effect::Dig { + count, + keep_count_expr, + .. + } => { + f(count); + if let Some(q) = keep_count_expr { + f(q); + } + } + Effect::Surveil { count, .. } => { + f(count); + } + Effect::BounceAll { count, .. } => { + if let Some(q) = count { + f(q); + } + } + Effect::EpicCopy { spell, .. } => { + // The epic copy is a full `ResolvedAbility` chain; walk its + // effect (and continuation branches) so its quantity slots + // are not invisible to callers. + visit_resolved_ability(spell, f); + } + Effect::CastCopyOfCard { count, .. } => { + if let Some(q) = count { + f(q); + } + } + Effect::CopyTokenOf { count, .. } => { + f(count); + } + Effect::CreateTokenCopyFromPool { + mv_bound, count, .. + } => { + f(mv_bound); + f(count); + } + Effect::PutCounter { count, .. } => { + f(count); + } + Effect::PutChosenCounter { count, .. } => { + f(count); + } + Effect::PutCounterAll { count, .. } => { + f(count); + } + Effect::ChooseCounterAdjustment { count, .. } => { + f(count); + } + Effect::MoveCounters { count, .. } => { + if let Some(q) = count { + f(q); + } + } + Effect::Animate { + power, toughness, .. + } => { + if let Some(v) = power { + visit_pt(v, f); + } + if let Some(v) = toughness { + visit_pt(v, f); + } + } + Effect::Discard { count, .. } => { + f(count); + } + Effect::SearchLibrary { count, .. } => { + f(count); + } + Effect::SearchOutsideGame { count, .. } => { + f(count); + } + Effect::RevealHand { count, .. } => { + if let Some(q) = count { + f(q); + } + } + Effect::ExileTop { count, .. } => { + f(count); + } + Effect::ExileFaceDownPile { count, .. } => { + f(count); + } + Effect::Connive { count, .. } => { + f(count); + } + Effect::AddPendingETBCounters { count, .. } => { + f(count); + } + Effect::PayCost { cost, scale, .. } => { + // The cost's own dynamic values (ManaDynamic, PayLife, + // Discard, PayEnergy/PaySpeed, and the recursive + // Composite/OneOf/EffectCost/PerCounter forms) resolve when + // the cost is paid during THIS effect's resolution. + cost.for_each_quantity_expr(f); + if let Some(q) = scale { + f(q); + } + } + Effect::PreventDamage { amount_dynamic, .. } => { + if let Some(q) = amount_dynamic { + f(q); + } + } + Effect::CreateDrawReplacement { + replacement_effect, .. + } => { + replacement_effect.for_each_quantity_expr(f); + } + Effect::CreatePlaneswalkReplacement { + replacement_effect, .. + } => { + replacement_effect.for_each_quantity_expr(f); + } + Effect::RollDie { + count, modifier, .. + } => { + f(count); + // CR 706.2: the (optional) Add/Subtract modifier quantity is + // resolved against each natural roll during THIS effect's + // resolution (`game/effects/roll_die.rs`). + if let Some(m) = modifier { + m.for_each_quantity_expr(f); + } + } + Effect::FlipCoins { count, .. } => { + f(count); + } + Effect::Mana { produced, .. } => { + // The production descriptor's dynamic `count` slots (Colorless, + // AnyOneColor, AnyOneColorAmongPermanents, …) resolve during + // THIS effect's resolution (`resolve_count` in + // `game/effects/mana.rs`). + produced.for_each_quantity_expr(f); + } + // CR 401.7: the countered spell's replacement destination may be a + // dynamic library slot, resolved when the counter moves the spell + // (`game/effects/counter.rs`). + Effect::Counter { + countered_spell_zone, + .. + } => match countered_spell_zone { + Some(SpellStackToGraveyardReplacement::Library { position }) => { + visit_library_position(position, f); + } + Some( + SpellStackToGraveyardReplacement::Exile + | SpellStackToGraveyardReplacement::Hand, + ) + | None => {} + }, + // CR 202.3 + CR 107.3e: the cumulative-threshold quantity is + // resolved up-front when the until-loop starts + // (`game/effects/exile_from_top_until.rs`). + Effect::ExileFromTopUntil { until, .. } => match until { + UntilCondition::NextMatches { .. } => {} + UntilCondition::CumulativeThreshold { threshold, .. } => f(threshold), + }, + // A proposition guess resolves both comparison sides live + // (`game/effects/opponent_guess.rs`). + Effect::OpponentGuess { subject, .. } => match &**subject { + GuessSubject::CommittedChoice { .. } => {} + GuessSubject::Proposition { lhs, rhs, .. } => { + f(lhs); + f(rhs); + } + }, + // Each conjured card's count — and the optional dynamic library + // slot — resolve during conjure resolution + // (`game/effects/conjure.rs`). + Effect::Conjure { + cards, + library_position, + .. + } => { + for card in cards { + f(&card.count); + } + if let Some(position) = library_position { + visit_library_position(position, f); + } + } + Effect::ArrangePlanarDeckTop { + count, keep_on_top, .. + } => { + f(count); + f(keep_on_top); + } + Effect::AssembleContraptions { count, .. } => { + f(count); + } + Effect::PutSticker { + count, + max_ticket_cost, + .. + } => { + f(count); + if let Some(q) = max_ticket_cost { + f(q); + } + } + Effect::ChooseAndSacrificeRest { + total_power_cap, + keeper_constraint, + .. + } => { + if let Some(q) = total_power_cap { + f(q); + } + // CR 101.4 + CR 701.21a: the keeper cardinality is resolved + // live when the chooser prompt is built. + if let Some(constraint) = keeper_constraint { + match constraint { + KeeperConstraint::ExactCount { count } => f(count), + } + } + } + Effect::GainEnergy { amount, .. } => { + f(amount); + } + Effect::GivePlayerCounter { count, .. } => { + f(count); + } + Effect::RevealUntil { count, .. } => { + f(count); + } + Effect::Discover { + mana_value_limit, .. + } => { + f(mana_value_limit); + } + Effect::PutAtLibraryPosition { + count, position, .. + } => { + f(count); + visit_library_position(position, f); + } + Effect::ChooseDrawnThisTurnPayOrTopdeck { + count, + life_payment, + .. + } => { + f(count); + f(life_payment); + } + Effect::Manifest { count, .. } => { + f(count); + } + Effect::Cloak { count, .. } => { + f(count); + } + Effect::GrantExtraLoyaltyActivations { amount, .. } => { + f(amount); + } + Effect::SkipNextTurn { count, .. } => { + f(count); + } + Effect::SkipNextStep { count, .. } => { + f(count); + } + Effect::AdditionalPhase { count, .. } => { + f(count); + } + Effect::Incubate { count, .. } => { + f(count); + } + Effect::Amass { count, .. } => { + f(count); + } + Effect::Monstrosity { count, .. } => { + f(count); + } + Effect::Renown { count, .. } => { + f(count); + } + Effect::Bolster { count, .. } => { + f(count); + } + Effect::Adapt { count, .. } => { + f(count); + } + Effect::Endure { amount, .. } => { + f(amount); + } + Effect::Seek { count, .. } => { + f(count); + } + Effect::SetLifeTotal { amount, .. } => { + f(amount); + } + Effect::Intensify { amount, .. } => { + f(amount); + } + Effect::ForEachCategory { action, .. } => { + // CR 608.2c: the per-member terminal action runs inside THIS + // resolution, so its counter quantity is a live slot. + match action { + ForEachCategoryAction::ExileFromPool { .. } => {} + ForEachCategoryAction::PutCounter { count, .. } => f(count), + } + } + Effect::StartYourEngines { .. } + | Effect::ApplyPostReplacementDamage { .. } + | Effect::EachDealsDamageEqualToPower { .. } + | Effect::PairWith { .. } + | Effect::Destroy { .. } + | Effect::Regenerate { .. } + | Effect::RemoveAllDamage { .. } + | Effect::CounterAll { .. } + | Effect::SetTapState { .. } + | Effect::DiscardCard { .. } + | Effect::DestroyAll { .. } + | Effect::GainControl { .. } + | Effect::GainControlAll { .. } + | Effect::ControlNextTurn { .. } + | Effect::Attach { .. } + | Effect::UnattachAll { .. } + | Effect::Fight { .. } + | Effect::Bounce { .. } + | Effect::Explore + | Effect::ExploreAll { .. } + | Effect::Investigate + | Effect::Tribute { .. } + | Effect::TimeTravel + | Effect::BecomeMonarch + | Effect::NoOp + | Effect::Proliferate + | Effect::ProliferateTarget { .. } + | Effect::Populate + | Effect::Clash + | Effect::Behold { .. } + | Effect::EndTheTurn + | Effect::EndCombatPhase + | Effect::Vote { .. } + | Effect::SeparateIntoPiles { .. } + | Effect::SwitchPT { .. } + | Effect::CopySpell { .. } + | Effect::Myriad + | Effect::Encore + | Effect::CombineHost { .. } + | Effect::ChooseAugmentAndCombineWithHost { .. } + | Effect::Meld { .. } + | Effect::ExileHaunting { .. } + | Effect::HideawayConceal { .. } + | Effect::CopyTokenBlockingAttacker { .. } + | Effect::BecomeCopy { .. } + | Effect::ChoosePermanent { .. } + | Effect::GainActivatedAbilitiesOfTarget { .. } + | Effect::ChooseCard { .. } + | Effect::ChooseCounterKind { .. } + | Effect::MultiplyCounter { .. } + | Effect::DoublePT { .. } + | Effect::DoublePTAll { .. } + | Effect::ReturnAsAura { .. } + | Effect::RegisterBending { .. } + | Effect::GenericEffect { .. } + | Effect::Cleanup { .. } + | Effect::Shuffle { .. } + | Effect::Transform { .. } + | Effect::FlipPermanent { .. } + | Effect::RevealFromHand { .. } + | Effect::Reveal { .. } + | Effect::RevealTop { .. } + | Effect::TargetOnly { .. } + | Effect::Choose { .. } + | Effect::SwapChosenLabels { .. } + | Effect::ChooseDamageSource { .. } + | Effect::Suspect { .. } + | Effect::Unsuspect { .. } + | Effect::PhaseOut { .. } + | Effect::PhaseIn { .. } + | Effect::ForceBlock { .. } + | Effect::ForceAttack { .. } + | Effect::SolveCase + | Effect::BecomePrepared { .. } + | Effect::BecomeUnprepared { .. } + | Effect::BecomeSaddled { .. } + | Effect::SetClassLevel { .. } + | Effect::CreateDelayedTrigger { .. } + | Effect::AddTargetReplacement { .. } + | Effect::AddRestriction { .. } + | Effect::ReduceNextSpellCost { .. } + | Effect::GrantNextSpellAbility { .. } + | Effect::AddPendingEntersModifications { .. } + | Effect::CreateEmblem { .. } + | Effect::CastFromZone { .. } + | Effect::FreeCastFromZones { .. } + | Effect::ExileResolvingSpellInsteadOfGraveyard { .. } + | Effect::CreateDamageReplacement { .. } + | Effect::LoseTheGame { .. } + | Effect::WinTheGame { .. } + | Effect::FlipCoin { .. } + | Effect::FlipCoinUntilLose { .. } + | Effect::RingTemptsYou + | Effect::VentureIntoDungeon + | Effect::VentureInto { .. } + | Effect::TakeTheInitiative + | Effect::Planeswalk + | Effect::ChaosEnsues + | Effect::ReverseTurnOrder + | Effect::RedistributeLifeTotals + | Effect::OpenAttractions { .. } + | Effect::RollToVisitAttractions + | Effect::AssembleContraptionsFromRollDifference + | Effect::CrankContraptions { .. } + | Effect::ReassembleContraption { .. } + | Effect::AssembleContraptionOnSprocket { .. } + | Effect::ReassembleContraptionOnSprocket { .. } + | Effect::ApplySticker { .. } + | Effect::ProcessRadCounters + | Effect::GrantCastingPermission { .. } + | Effect::ChooseFromZone { .. } + | Effect::RememberCard { .. } + | Effect::ChooseObjectsIntoTrackedSet { .. } + | Effect::EachPlayerCopyChosen { .. } + | Effect::Exploit { .. } + | Effect::LoseAllPlayerCounters { .. } + | Effect::Heist { .. } + | Effect::HeistExile + | Effect::Cascade + | Effect::Ripple { .. } + | Effect::MiracleCast { .. } + | Effect::MadnessCast { .. } + | Effect::PutOnTopOrBottom { .. } + | Effect::GiftDelivery { .. } + | Effect::Goad { .. } + | Effect::GoadAll { .. } + | Effect::Detain { .. } + | Effect::SetRoomDoorLock { .. } + | Effect::ExchangeControl { .. } + | Effect::ChangeTargets { .. } + | Effect::ManifestDread + | Effect::TurnFaceUp { .. } + | Effect::TurnFaceDown { .. } + | Effect::ExtraTurn { .. } + | Effect::Double { .. } + | Effect::RuntimeHandled { .. } + | Effect::Specialize + | Effect::Learn + | Effect::Forage + | Effect::Harness + | Effect::CollectEvidence { .. } + | Effect::BlightEffect { .. } + | Effect::ExchangeLifeWithStat { .. } + | Effect::ExchangeLifeTotals { .. } + | Effect::SetDayNight { .. } + | Effect::GiveControl { .. } + | Effect::RemoveFromCombat { .. } + | Effect::BecomeBlocked { .. } + | Effect::ApplyPerpetual { .. } + | Effect::DraftFromSpellbook { .. } + | Effect::ChooseOneOf { .. } + | Effect::Unimplemented { .. } => {} + } + } + /// CR 107.3 + CR 608.2c: Returns the `QuantityExpr` carrying this effect's /// primary count/amount, for the full class of count- and amount-bearing /// effects (token creation, counters, draws, damage, mill, discard, etc.). diff --git a/crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs b/crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs new file mode 100644 index 0000000000..9f30342662 --- /dev/null +++ b/crates/engine/tests/integration/issue_5991_malfegor_discard_sacrifice.rs @@ -0,0 +1,566 @@ +//! Regression for GitHub issue #5991 — Malfegor's ETB ability that forces the +//! opponent to sacrifice creatures scaled to the discarded hand size. +//! +//! Oracle (verified real MTG card text, Alara Reborn): +//! "Flying\nWhen Malfegor enters the battlefield, discard your hand, then +//! each opponent sacrifices a creature of their choice for each card +//! discarded this way." +//! +//! A `parse_oracle_text` probe against this exact text (dumped in the issue +//! investigation, not included here) confirmed the AST parses correctly and +//! matches this test's `TRIGGER_BODY` shape exactly: +//! +//! DiscardCard { count: HandSize(Controller), target: Controller } +//! sub_ability: +//! Sacrifice { +//! target: Typed([Creature]), +//! count: FilteredTrackedSetSize { filter: Typed([Card]), caused_by: Discarded }, +//! } +//! player_scope: Opponent +//! +//! This test drives the real resolution chain end to end via +//! `resolve_ability_chain` (the same production entry point every triggered +//! ability resolves through — mirrors the established convention for +//! player_scope mass-effect triggers, see +//! `aclazotz_attack_discard_multi_opponent.rs`), not a hand-built +//! `ResolvedAbility` — the ability comes from the real parser +//! (`parse_effect_chain`) resolved via `build_resolved_from_def`. +//! +//! Discriminating scenario: the controller discards a 2-card hand, and the +//! single opponent controls 3 creatures. Per the real card, the opponent must +//! be asked to sacrifice exactly 2 of their 3 creatures (a real interactive +//! `EffectZoneChoice` prompt with `count == 2`), not a fixed count of 1 and +//! not 0 — the two most plausible "malfunctioning" symptoms a scoped defect +//! in this class of card could produce. + +use engine::game::ability_utils::build_resolved_from_def; +use engine::game::effects::resolve_ability_chain; +use engine::game::engine::apply; +use engine::game::zones::create_object; +use engine::parser::oracle_effect::parse_effect_chain; +use engine::types::ability::{AbilityKind, ResolvedAbility}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::format::FormatConfig; +use engine::types::game_state::{GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const TRIGGER_BODY: &str = "discard your hand, then each opponent sacrifices a creature \ + of their choice for each card discarded this way."; + +fn malfegor_etb_ability(controller: PlayerId, source_id: ObjectId) -> ResolvedAbility { + let def = parse_effect_chain(TRIGGER_BODY, AbilityKind::Spell); + build_resolved_from_def(&def, source_id, controller) +} + +fn add_hand_cards(state: &mut GameState, base_card_id: u64, player: PlayerId, n: usize) { + for i in 0..n { + create_object( + state, + CardId(base_card_id + i as u64), + player, + "Forest".to_string(), + Zone::Hand, + ); + } +} + +/// Bare `create_object` leaves `card_types` at its empty default (it does not +/// look up real card data by name), so a battlefield creature must have +/// `CoreType::Creature` pushed onto BOTH `card_types` and `base_card_types` +/// (the latter survives a layer recompute, which reverts `card_types` from +/// it) — otherwise the Sacrifice effect's `Typed([Creature])` target filter +/// matches nothing and the eligible pool is silently empty. Mirrors +/// `make_creature` in `descendants_fury_sacrificed_referent_4795.rs`. +fn add_battlefield_creatures(state: &mut GameState, base_card_id: u64, player: PlayerId, n: usize) { + for i in 0..n { + let id = create_object( + state, + CardId(base_card_id + i as u64), + player, + "Grizzly Bears".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).expect("object just created"); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types.core_types.push(CoreType::Creature); + } +} + +fn battlefield_count(state: &GameState, player: PlayerId) -> usize { + state + .battlefield + .iter() + .filter(|id| { + state + .objects + .get(id) + .is_some_and(|obj| obj.controller == player) + }) + .count() +} + +fn hand_len(state: &GameState, player: PlayerId) -> usize { + state + .players + .iter() + .find(|p| p.id == player) + .expect("player exists") + .hand + .len() +} + +/// CR 701.9a (discard) + CR 701.21a (sacrifice) + CR 608.2c: P0 (the caster) +/// discards a 2-card hand +/// (a mandatory whole-hand discard, no choice needed since hand size == +/// discard count), then P1 (the lone opponent, controlling 3 creatures) must +/// sacrifice exactly 2 of them — the discarded-card count, not a fixed 1 and +/// not 0. This is the exact shape reported in #5991: "forces the opponent to +/// sacrifice their creatures after I discard my hand." +#[test] +fn malfegor_sacrifice_count_scales_with_discarded_hand_size() { + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Malfegor".to_string(), + Zone::Battlefield, + ); + + add_hand_cards(&mut state, 100, PlayerId(0), 2); + add_battlefield_creatures(&mut state, 200, PlayerId(1), 3); + + assert_eq!(hand_len(&state, PlayerId(0)), 2, "P0 starts with 2 cards"); + assert_eq!( + battlefield_count(&state, PlayerId(1)), + 3, + "P1 starts with 3 creatures" + ); + + let ability = malfegor_etb_ability(PlayerId(0), source); + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); + + // The whole-hand discard is mandatory and unambiguous (hand size == + // discard count), so it resolves without an interactive prompt, straight + // into the sacrifice's own EffectZoneChoice prompt. + assert_eq!( + hand_len(&state, PlayerId(0)), + 0, + "P0's whole hand must be discarded before the sacrifice count is read" + ); + + let WaitingFor::EffectZoneChoice { + player, + count, + cards, + up_to, + .. + } = state.waiting_for.clone() + else { + panic!( + "expected an EffectZoneChoice sacrifice prompt for the opponent, got {:?}", + state.waiting_for + ); + }; + assert_eq!( + player, + PlayerId(1), + "the OPPONENT must be the one prompted to sacrifice" + ); + assert!(!up_to, "the sacrifice is mandatory, not up-to"); + assert_eq!( + count, 2, + "sacrifice count must equal the 2 cards discarded this way, not a fixed 1" + ); + assert_eq!(cards.len(), 3, "all 3 of P1's creatures must be eligible"); + + // Complete the interactive choice: P1 picks 2 of their 3 creatures. + let picks: Vec = cards.iter().take(2).copied().collect(); + apply( + &mut state, + PlayerId(1), + GameAction::SelectCards { + cards: picks.clone(), + }, + ) + .expect("sacrifice choice should succeed"); + + assert_eq!( + battlefield_count(&state, PlayerId(1)), + 1, + "P1 must end with exactly 1 creature left (3 - 2 sacrificed)" + ); + for picked in &picks { + assert!( + !state.battlefield.contains(picked), + "sacrificed creature {picked:?} must have left the battlefield" + ); + } +} + +/// Negative/no-regression guard: an empty hand discards nothing, so the +/// tracked set is empty and the opponent must sacrifice 0 creatures — a +/// legal no-op, not an erroneous fixed-1 sacrifice. +#[test] +fn malfegor_empty_hand_discard_causes_no_sacrifice() { + use engine::types::ability::EffectKind; + use engine::types::events::GameEvent; + + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Malfegor".to_string(), + Zone::Battlefield, + ); + + add_battlefield_creatures(&mut state, 200, PlayerId(1), 2); + + let ability = malfegor_etb_ability(PlayerId(0), source); + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); + + // Reach guard: the negative assertions below are only meaningful if the + // discard resolution step actually ran. The discard resolver emits + // `EffectResolved` for this source even when the hand is empty, so an + // upstream short-circuit that skipped the discard/tracked-set path + // entirely would fail here rather than vacuously pass. + assert!( + events.iter().any(|e| matches!( + e, + GameEvent::EffectResolved { + kind: EffectKind::Discard | EffectKind::DiscardCard, + source_id, + .. + } if *source_id == source + )), + "the (empty) discard resolution step must have run; events: {events:?}" + ); + assert!( + !events + .iter() + .any(|e| matches!(e, GameEvent::Discarded { .. })), + "no card may actually be discarded from an empty hand" + ); + + assert_eq!( + battlefield_count(&state, PlayerId(1)), + 2, + "opponent must keep both creatures when nothing was discarded" + ); + assert!( + !matches!(state.waiting_for, WaitingFor::EffectZoneChoice { .. }), + "an empty discard must not raise a sacrifice prompt, got {:?}", + state.waiting_for + ); +} + +/// CR 608.2c + CR 122.6: matcher-boundary sibling of the Malfegor shape, from +/// the #5991 review — a producer (whole-hand discard) chained into a token +/// with FIXED count and FIXED P/T whose only tracked-set reference is a +/// SECONDARY quantity slot: `enter_with_counters: [(+1/+1, TrackedSetSize)]` +/// ("... with a +1/+1 counter on it for each card discarded this way"). +/// +/// The chain is hand-built (the parent discard comes from the real parser; +/// the token continuation is constructed directly) because this guards the +/// tracked-set publish CLASSIFICATION at the `resolve_ability_chain` +/// production boundary, not any single card's parse. With a primary-count-only +/// classification (`count`/`power`/`toughness` inspection), the discard never +/// publishes its set and the token enters with ZERO counters; the exhaustive +/// `for_each_quantity_expr` walk makes it enter with one counter per +/// discarded card. +#[test] +fn tracked_entry_counter_token_receives_discarded_count_counters() { + use engine::types::ability::Effect; + use engine::types::ability::{PtValue, QuantityExpr, QuantityRef, TargetFilter}; + use engine::types::counter::CounterType; + use engine::types::mana::ManaColor; + + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Entry Counter Producer".to_string(), + Zone::Battlefield, + ); + + add_hand_cards(&mut state, 100, PlayerId(0), 2); + + // Parent: the same real-parser whole-hand discard the Malfegor test uses. + let def = parse_effect_chain("discard your hand.", AbilityKind::Spell); + let mut ability = build_resolved_from_def(&def, source, PlayerId(0)); + assert!( + ability.sub_ability.is_none(), + "the bare discard must have no continuation before we attach one" + ); + + // Continuation: fixed-count, fixed-P/T token whose ONLY tracked-set + // reference is the entry-counter quantity. + ability.sub_ability = Some(Box::new(ResolvedAbility::new( + Effect::Token { + name: "Zombie".to_string(), + power: PtValue::Fixed(2), + toughness: PtValue::Fixed(2), + types: vec!["Creature".to_string(), "Zombie".to_string()], + colors: vec![ManaColor::Black], + keywords: vec![], + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to: None, + enters_attacking: false, + supertypes: vec![], + static_abilities: vec![], + enter_with_counters: vec![( + CounterType::Plus1Plus1, + QuantityExpr::Ref { + qty: QuantityRef::TrackedSetSize, + }, + )], + }, + vec![], + source, + PlayerId(0), + ))); + + let before: Vec = state.battlefield.iter().copied().collect(); + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); + + assert_eq!( + hand_len(&state, PlayerId(0)), + 0, + "the whole hand must be discarded before the token is created" + ); + + let new_tokens: Vec = state + .battlefield + .iter() + .filter(|id| !before.contains(id)) + .copied() + .collect(); + assert_eq!( + new_tokens.len(), + 1, + "exactly one token must be created (fixed count 1)" + ); + let token = state + .objects + .get(&new_tokens[0]) + .expect("token object exists"); + assert_eq!( + token.counters.get(&CounterType::Plus1Plus1).copied(), + Some(2), + "the token must enter with one +1/+1 counter per discarded card (2), \ + not zero — zero means the discard never published its tracked set" + ); +} + +/// Builds the shared producer→consumer chain of the nested-carrier seam +/// regressions below: the same real-parser whole-hand discard the Malfegor +/// test uses (the tracked-set PRODUCER), with `continuation` attached as the +/// hand-built consumer sub-ability. Mirrors +/// `tracked_entry_counter_token_receives_discarded_count_counters` — these +/// tests guard the tracked-set publish CLASSIFICATION at the +/// `resolve_ability_chain` production boundary, not any single card's parse. +fn discard_hand_with_continuation( + source: ObjectId, + continuation: engine::types::ability::Effect, +) -> ResolvedAbility { + let def = parse_effect_chain("discard your hand.", AbilityKind::Spell); + let mut ability = build_resolved_from_def(&def, source, PlayerId(0)); + assert!( + ability.sub_ability.is_none(), + "the bare discard must have no continuation before we attach one" + ); + ability.sub_ability = Some(Box::new(ResolvedAbility::new( + continuation, + vec![], + source, + PlayerId(0), + ))); + ability +} + +/// CR 608.2c + CR 106.1b: nested-carrier seam from the #5991 review — a +/// producer (whole-hand discard) chained into an `Effect::Mana` whose +/// `ManaProduction::Colorless` count is the ONLY tracked-set reference +/// ("add {C} for each card discarded this way"). The dynamic production +/// count is resolved live in `game/effects/mana.rs::resolve_count`, so the +/// discard must publish its set; with `Effect::Mana` classified quantity-free +/// (the pre-fix state of `for_each_quantity_expr`), the set is never +/// published and ZERO mana is added. +#[test] +fn tracked_mana_production_count_adds_discarded_count_mana() { + use engine::types::ability::{Effect, ManaProduction, QuantityExpr, QuantityRef}; + use engine::types::mana::ManaType; + + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Mana Producer".to_string(), + Zone::Battlefield, + ); + + add_hand_cards(&mut state, 100, PlayerId(0), 2); + + let ability = discard_hand_with_continuation( + source, + Effect::Mana { + produced: ManaProduction::Colorless { + count: QuantityExpr::Ref { + qty: QuantityRef::TrackedSetSize, + }, + }, + restrictions: vec![], + grants: vec![], + expiry: None, + target: None, + }, + ); + + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); + + assert_eq!( + hand_len(&state, PlayerId(0)), + 0, + "the whole hand must be discarded before the mana count is read" + ); + assert_eq!( + state.players[0].mana_pool.count_color(ManaType::Colorless), + 2, + "the pool must gain one {{C}} per discarded card (2), not zero — \ + zero means the discard never published its tracked set" + ); +} + +/// CR 608.2c + CR 119.4 + CR 118.12: nested-carrier seam from the #5991 +/// review — a producer (whole-hand discard) chained into an `Effect::PayCost` +/// whose `AbilityCost::PayLife` amount is the ONLY tracked-set reference +/// ("pay 1 life for each card discarded this way"). The cost's dynamic amount +/// is resolved live in the payment authority +/// (`costs::pay_ability_cost_for_resolution`), so the discard must publish +/// its set; with `PayCost` visiting only `scale` (the pre-fix state of +/// `for_each_quantity_expr`), the set is never published and ZERO life is +/// paid. +#[test] +fn tracked_pay_cost_life_amount_pays_discarded_count_life() { + use engine::types::ability::{AbilityCost, Effect, QuantityExpr, QuantityRef, TargetFilter}; + + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Life Payer".to_string(), + Zone::Battlefield, + ); + + add_hand_cards(&mut state, 100, PlayerId(0), 2); + let life_before = state.players[0].life; + + let ability = discard_hand_with_continuation( + source, + Effect::PayCost { + cost: AbilityCost::PayLife { + amount: QuantityExpr::Ref { + qty: QuantityRef::TrackedSetSize, + }, + }, + scale: None, + payer: TargetFilter::Controller, + }, + ); + + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); + + assert_eq!( + hand_len(&state, PlayerId(0)), + 0, + "the whole hand must be discarded before the life payment is read" + ); + assert!( + !state.cost_payment_failed_flag, + "the fixed-amount life payment must succeed" + ); + assert_eq!( + state.players[0].life, + life_before - 2, + "the payer must lose one life per discarded card (2), not zero — \ + zero means the discard never published its tracked set" + ); +} + +/// CR 608.2c + CR 706.2: nested-carrier seam from the #5991 review — a +/// producer (whole-hand discard) chained into an `Effect::RollDie` whose +/// `DieRollModifier::Add` value is the ONLY tracked-set reference ("roll a +/// die and add the number of cards discarded this way"). The modifier is +/// resolved live against the natural roll in `game/effects/roll_die.rs`, so +/// the discard must publish its set. A one-sided die pins the natural roll +/// to 1, making the modified result deterministic: 1 + 2 discarded = 3; with +/// `RollDie` visiting only `count` (the pre-fix state of +/// `for_each_quantity_expr`), the set is never published and the result +/// stays at the natural 1. +#[test] +fn tracked_die_roll_modifier_adds_discarded_count_to_result() { + use engine::types::ability::{DieRollModifier, Effect, QuantityExpr, QuantityRef}; + use engine::types::events::GameEvent; + + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Die Roller".to_string(), + Zone::Battlefield, + ); + + add_hand_cards(&mut state, 100, PlayerId(0), 2); + + let ability = discard_hand_with_continuation( + source, + Effect::RollDie { + count: QuantityExpr::Fixed { value: 1 }, + sides: 1, + results: vec![], + modifier: Some(DieRollModifier::Add { + value: QuantityExpr::Ref { + qty: QuantityRef::TrackedSetSize, + }, + }), + }, + ); + + let mut events = Vec::new(); + resolve_ability_chain(&mut state, &ability, &mut events, 0).unwrap(); + + assert_eq!( + hand_len(&state, PlayerId(0)), + 0, + "the whole hand must be discarded before the roll modifier is read" + ); + let rolled: Vec> = events + .iter() + .filter_map(|e| match e { + GameEvent::DieRolled { result, .. } => Some(*result), + _ => None, + }) + .collect(); + assert_eq!( + rolled, + vec![Some(3)], + "the d1 natural roll of 1 plus one per discarded card (2) must give \ + a modified result of 3 — a result of 1 means the discard never \ + published its tracked set" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index d77e4c2c2a..c338fbaa59 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -578,6 +578,7 @@ mod issue_5983_sothera_dies_edict; mod issue_5984_aloy_discover_on_attack; mod issue_5988_braids_arisen_nightmare; mod issue_5989_ardyn_exile_copy; +mod issue_5991_malfegor_discard_sacrifice; mod issue_5992_golem_artisan; mod issue_5996_planetarium_look_cast; mod issue_5997_malcolm_treasure_trigger;