diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index d14792ef3a..dee7adee44 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -8329,8 +8329,8 @@ fn is_bloodthirst_x_etb_replacement(replacement: &ReplacementDefinition) -> bool /// /// Counter-count linkage: the ranged `EffectZoneChoice` Sacrifice completion /// stamps `state.last_effect_count` (the number of creatures chosen). -/// `QuantityRef::EventContextAmount`'s resolver falls back through -/// `last_effect_count`, so the `PutCounter` count reads exactly the number +/// `QuantityRef::PreviousEffectCount` reads that continuation-local tally +/// directly, so an enclosing trigger's scalar amount cannot shadow the number /// sacrificed. For Devour N > 1 the count is wrapped in /// `QuantityExpr::Multiply { factor: n, .. }` (CR 702.82a "N counters per /// creature sacrificed"). `PreviousEffectAmount` is NOT used — it reads @@ -8418,19 +8418,19 @@ pub fn synthesize_devour(face: &mut CardFace) { let quality_noun = type_filter_noun(quality, false); let quality_noun_plural = type_filter_noun(quality, true); - // CR 122.1: N +1/+1 counters per creature sacrificed this way. The - // per-creature count is `EventContextAmount` (resolves to the number - // the ranged Sacrifice choice stamped into `last_effect_count`); for + // CR 702.82a / CR 702.82c: N +1/+1 counters per sacrificed permanent. The + // per-sacrifice count is `PreviousEffectCount` (the number the ranged + // Sacrifice choice stamped into `last_effect_count`); for // N > 1 it is scaled by `factor: n`. let counter_count = if n == 1 { QuantityExpr::Ref { - qty: QuantityRef::EventContextAmount, + qty: QuantityRef::PreviousEffectCount, } } else { QuantityExpr::Multiply { factor: n as i32, inner: Box::new(QuantityExpr::Ref { - qty: QuantityRef::EventContextAmount, + qty: QuantityRef::PreviousEffectCount, }), } }; @@ -8497,7 +8497,7 @@ pub fn synthesize_devour(face: &mut CardFace) { /// /// `expected_n` is load-bearing: a card carrying both a printed enters-with-K /// replacement and `Keyword::Devour { n: N≠K, .. }` must not dedupe — the -/// `Multiply` factor (N) for N > 1 and the bare `EventContextAmount` (N == 1) +/// `Multiply` factor (N) for N > 1 and the bare `PreviousEffectCount` (N == 1) /// discriminate the count. /// /// `expected_quality` is equally load-bearing (CR 702.82c): a land-quality Devour @@ -8545,13 +8545,13 @@ fn is_devour_etb_replacement( } let expected_count = if expected_n == 1 { QuantityExpr::Ref { - qty: QuantityRef::EventContextAmount, + qty: QuantityRef::PreviousEffectCount, } } else { QuantityExpr::Multiply { factor: expected_n as i32, inner: Box::new(QuantityExpr::Ref { - qty: QuantityRef::EventContextAmount, + qty: QuantityRef::PreviousEffectCount, }), } }; @@ -22582,7 +22582,7 @@ mod devour_synthesis_tests { /// CR 702.82a: Devour 1 synthesizes one `Moved`/`SelfRef` replacement /// whose execute chain is `Sacrifice(UpTo) → PutCounter(P1P1, SelfRef)`, - /// and whose `PutCounter` count is the bare `EventContextAmount` (one + /// and whose `PutCounter` count is the bare `PreviousEffectCount` (one /// counter per creature sacrificed). #[test] fn synthesize_devour_1_builds_sacrifice_then_counter_chain() { @@ -22629,7 +22629,7 @@ mod devour_synthesis_tests { "Devour sacrifices creatures the controller controls" ); - // Sub-ability: PutCounter of EventContextAmount P1P1 counters on self. + // Sub-ability: PutCounter of PreviousEffectCount P1P1 counters on self. let sub = execute .sub_ability .as_deref() @@ -22647,11 +22647,10 @@ mod devour_synthesis_tests { assert_eq!( *count, QuantityExpr::Ref { - qty: QuantityRef::EventContextAmount + qty: QuantityRef::PreviousEffectCount }, "Devour 1 places exactly one counter per creature sacrificed — \ - the count must be the bare EventContextAmount (NOT \ - PreviousEffectAmount, which the ranged Sacrifice never stamps)" + the count must be the direct continuation-local PreviousEffectCount" ); } @@ -22680,7 +22679,7 @@ mod devour_synthesis_tests { QuantityExpr::Multiply { factor: 2, inner: Box::new(QuantityExpr::Ref { - qty: QuantityRef::EventContextAmount + qty: QuantityRef::PreviousEffectCount }), }, "Devour 2 places 2 counters per creature sacrificed (CR 702.82a)" diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index bf91365caa..9f750b8ae3 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2110,6 +2110,7 @@ fn legacy_quantity_ref(x: &QuantityRef) -> bool { | QuantityRef::TrackedSetAggregate { .. } | QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::TurnsTaken | QuantityRef::CrimesCommittedThisTurn | QuantityRef::ChosenNumber @@ -6125,6 +6126,7 @@ fn rw_quantity_ref(x: &QuantityRef) -> RwProfile { // (member-invariant under uniformity). QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::TurnsTaken | QuantityRef::CrimesCommittedThisTurn | QuantityRef::AttackedThisTurn { .. } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 53f5063b8d..e67751d38f 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -2170,6 +2170,7 @@ fn scan_quantity_ref(x: &QuantityRef, mode: ScanMode) -> Axes { }, QuantityRef::ExiledFromHandThisResolution => Axes::NONE, QuantityRef::PreviousEffectAmount { .. } => Axes::NONE, + QuantityRef::PreviousEffectCount => Axes::NONE, QuantityRef::LifeLostThisTurn { player } => { let mut acc = Axes { event: false, diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 584ba295ff..2272f6b1fa 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -1586,6 +1586,7 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { } QuantityRef::VoteCount { choice_index } => format!("# of votes for choice {choice_index}"), QuantityRef::PreviousEffectAmount { .. } => "amount from preceding effect".into(), + QuantityRef::PreviousEffectCount => "count from preceding effect".into(), QuantityRef::TrackedSetSize => "cards moved".into(), QuantityRef::FilteredTrackedSetSize { filter, .. } => { format!("filtered tracked set ({})", fmt_target(filter)) @@ -8235,6 +8236,7 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) { QuantityRef::DistinctCounterKindsAmong { .. } => ("DistinctCounterKindsAmong", Handled), QuantityRef::VoteCount { .. } => ("VoteCount", Handled), QuantityRef::PreviousEffectAmount { .. } => ("PreviousEffectAmount", Handled), + QuantityRef::PreviousEffectCount => ("PreviousEffectCount", Handled), QuantityRef::TrackedSetSize => ("TrackedSetSize", Handled), QuantityRef::FilteredTrackedSetSize { .. } => ("FilteredTrackedSetSize", Handled), QuantityRef::TrackedSetAggregate { .. } => ("TrackedSetAggregate", Handled), diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 28c19c632b..87ada1ad68 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3409,6 +3409,7 @@ fn quantity_ref_counts_population_matching( | QuantityRef::TrackedSetAggregate { .. } | QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::LifeLostThisTurn { .. } | QuantityRef::PartySize { .. } | QuantityRef::UnspentMana { .. } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index a904a3329a..8b369025e2 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19012,9 +19012,11 @@ mod stage2_injector_tests { // Identity re-established, not assumed: `9869a19f28c791ee`, // `2bc316e3aa0297f8`, `8df98486627bfe15` at the new coordinates — the same // three digests this log has carried since the first merge. - "game/effects/mod.rs:7002".to_string(), - "game/effects/mod.rs:7079".to_string(), - "game/effects/mod.rs:10317".to_string(), + // `PreviousEffectCount` classification adds one line above all three producers, + // so they move uniformly to `:7003/:7080/:10318`; no prompt site changes. + "game/effects/mod.rs:7003".to_string(), + "game/effects/mod.rs:7080".to_string(), + "game/effects/mod.rs:10318".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index de41a8dcf7..e1a1ddfdd4 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -2906,6 +2906,7 @@ fn quantity_ref_reads_zone(qty: &QuantityRef, zone: Zone) -> bool { | QuantityRef::TrackedSetAggregate { .. } | QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::LifeLostThisTurn { .. } | QuantityRef::Speed { .. } | QuantityRef::EventContextAmount @@ -3234,6 +3235,7 @@ fn quantity_ref_reads_life(qty: &QuantityRef) -> bool { | QuantityRef::TrackedSetAggregate { .. } | QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::PartySize { .. } | QuantityRef::UnspentMana { .. } | QuantityRef::Speed { .. } diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index f4497f2719..acfaaa8b70 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -972,6 +972,7 @@ fn quantity_ref_uses_unspent_mana(qty: &QuantityRef) -> bool { | QuantityRef::TrackedSetAggregate { .. } | QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::LifeLostThisTurn { .. } | QuantityRef::PartySize { .. } | QuantityRef::Speed { .. } @@ -1301,6 +1302,7 @@ fn quantity_ref_uses_object_count(qty: &QuantityRef) -> bool { | QuantityRef::TrackedSetAggregate { .. } | QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::LifeLostThisTurn { .. } | QuantityRef::Speed { .. } | QuantityRef::EventContextAmount @@ -1596,6 +1598,7 @@ fn quantity_ref_characteristic_reads(qty: &QuantityRef, depth: u32) -> Character | QuantityRef::TrackedSetSize | QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::LifeLostThisTurn { .. } | QuantityRef::UnspentMana { .. } | QuantityRef::Speed { .. } @@ -1860,6 +1863,7 @@ fn entered_object_perturbs_quantity_ref( | QuantityRef::TrackedSetAggregate { .. } | QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::LifeLostThisTurn { .. } | QuantityRef::Speed { .. } | QuantityRef::EventContextAmount @@ -3990,6 +3994,9 @@ fn resolve_ref( // (Contest of Claws). 0 when the preceding effect dealt no excess. DamageChannel::Excess => state.last_effect_excess_amount.unwrap_or(0), }, + // Read the preceding continuation-local effect count directly. + // An unavailable count resolves to zero. + QuantityRef::PreviousEffectCount => state.last_effect_count.unwrap_or(0), // CR 608.2c: "for each [thing] this way" — read the most recent tracked set size. QuantityRef::TrackedSetSize => state .tracked_object_sets diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 63d506e5ac..9a12a48395 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -10408,6 +10408,7 @@ fn quantity_ref_binding_diverges(qty: &QuantityRef) -> bool { | QuantityRef::TrackedSetAggregate { .. } | QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::TimesCostPaidThisResolution // CR 608.2c: the secret-number ledger is populated BY the // resolution that ran the choice (Wheel of Misfortune, Menacing Ogre) and @@ -13847,6 +13848,7 @@ fn quantity_ref_refs_cost_paid_object(qty: &QuantityRef) -> bool { | QuantityRef::TrackedSetAggregate { .. } | QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::LifeLostThisTurn { .. } | QuantityRef::PartySize { .. } | QuantityRef::UnspentMana { .. } diff --git a/crates/engine/src/game/triggers_devour_runtime_tests.rs b/crates/engine/src/game/triggers_devour_runtime_tests.rs index 1e1a74657f..a3d91cf58c 100644 --- a/crates/engine/src/game/triggers_devour_runtime_tests.rs +++ b/crates/engine/src/game/triggers_devour_runtime_tests.rs @@ -5,8 +5,8 @@ //! `PostReplacementContinuation` and drains it after the move completes, //! raising a ranged sacrifice `EffectZoneChoice`. The Sacrifice //! completion stamps `state.last_effect_count`, which the chained -//! `PutCounter` sub-ability's `QuantityRef::EventContextAmount` reads via -//! its `.or(last_effect_count)` fallback. +//! `PutCounter` sub-ability reads directly through +//! `QuantityRef::PreviousEffectCount`. //! //! Lives in `game/triggers.rs` rather than `database/synthesis.rs::tests` //! so it can reach the `pub(super)` post-replacement-continuation drain @@ -16,11 +16,15 @@ use crate::database::synthesis::synthesize_all; use crate::game::printed_cards::apply_card_face_to_object; use crate::game::zones::{create_object, move_to_zone}; -use crate::types::ability::{EffectKind, PtValue, TargetFilter, TypeFilter}; +use crate::types::ability::{ + EffectKind, PtValue, QuantityExpr, QuantityModification, QuantityRef, ReplacementDefinition, + TargetFilter, TypeFilter, +}; use crate::types::actions::GameAction; use crate::types::card::CardFace; use crate::types::card_type::CoreType; use crate::types::counter::CounterType; +use crate::types::events::GameEvent; use crate::types::game_state::{GameState, WaitingFor}; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::keywords::Keyword; @@ -214,6 +218,28 @@ fn p1p1(state: &GameState, id: ObjectId) -> u32 { .unwrap_or(0) } +/// Install the AddCounter quantity replacement used by Doubling Season-class +/// effects without depending on a particular card's parser output. +fn install_counter_doubler(state: &mut GameState, controller: PlayerId) { + let card_id = CardId(state.next_object_id); + let id = create_object( + state, + card_id, + controller, + "Counter Doubler".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&id) + .expect("counter doubler exists") + .replacement_definitions + .push( + ReplacementDefinition::new(ReplacementEvent::AddCounter) + .quantity_modification(QuantityModification::DOUBLE), + ); +} + /// Drive a Devour creature's Hand→Battlefield ZoneChange through the /// replacement pipeline, then drain the post-replacement continuation — /// the same call `stack.rs:575` makes during real spell resolution. @@ -331,7 +357,7 @@ fn devour_etb_raises_ranged_sacrifice_prompt() { /// two creatures to Devour 1 places exactly two +1/+1 counters on the /// entering permanent. Under v1's `PreviousEffectAmount` route this would /// resolve to 0 (the ranged Sacrifice never stamps `last_effect_amount`); -/// under v2's `EventContextAmount` it reads `last_effect_count = 2`. +/// under the direct `PreviousEffectCount` route it reads `last_effect_count = 2`. #[test] fn devour_1_full_sacrifice_places_one_counter_per_creature() { let face = devour_face("Gorger Wurm", 1); @@ -375,7 +401,7 @@ fn devour_1_full_sacrifice_places_one_counter_per_creature() { /// CR 702.82a: an empty sacrifice is legal — the Devour creature enters /// with 0 counters. NOTE: this case alone does NOT discriminate the v1 -/// linkage bug (both `PreviousEffectAmount` and `EventContextAmount` +/// linkage bug (both `PreviousEffectAmount` and `PreviousEffectCount` /// resolve to 0 here). It is paired with the full-sacrifice test above — /// that test is the true linkage-bug discriminator. #[test] @@ -405,7 +431,7 @@ fn devour_1_empty_sacrifice_enters_with_zero_counters() { /// CR 702.82a: Devour 2 places N=2 counters per creature sacrificed. /// One sacrifice → 2 counters, via the synthesizer's /// `QuantityExpr::Multiply { factor: 2, .. }` wrapping -/// `EventContextAmount`. +/// `PreviousEffectCount`. #[test] fn devour_2_one_sacrifice_places_two_counters() { let face = devour_face("Mycoloth", 2); @@ -426,6 +452,93 @@ fn devour_2_one_sacrifice_places_two_counters() { ); } +/// CR 702.82a + CR 614.16: Devour's continuation count is distinct +/// from a concurrently live enclosing event amount. Two sacrifices for Mycoloth +/// (Devour 2) make four counters, then the AddCounter doubler makes eight. +#[test] +fn devour_uses_previous_effect_count_not_outer_event_amount() { + let face = devour_face("Mycoloth", 2); + let (mut state, devour) = drive_devour_etb_with_battlefield(&face, PlayerId(0), |state| { + battlefield_creature(state, PlayerId(0), "Sac Fodder 0"); + battlefield_creature(state, PlayerId(0), "Sac Fodder 1"); + install_counter_doubler(state, PlayerId(0)); + }); + state.current_trigger_event = Some(GameEvent::DamageDealt { + source_id: ObjectId(999), + target: crate::types::ability::TargetRef::Player(PlayerId(1)), + amount: 1, + is_combat: false, + excess: 0, + }); + let event_amount = QuantityExpr::Ref { + qty: QuantityRef::EventContextAmount, + }; + assert_eq!( + crate::game::quantity::resolve_quantity(&state, &event_amount, PlayerId(0), devour), + 1, + "reach-guard: the generic event-context ref still sees the outer scalar event" + ); + + let WaitingFor::EffectZoneChoice { cards, .. } = &state.waiting_for else { + panic!("expected the Devour sacrifice choice"); + }; + let chosen: Vec = cards.iter().copied().take(2).collect(); + assert_eq!(chosen.len(), 2, "two creatures must be eligible for Devour"); + crate::game::engine::apply_as_current(&mut state, GameAction::SelectCards { cards: chosen }) + .unwrap(); + + assert_eq!( + p1p1(&state, devour), + 8, + "2 sacrifices × Devour 2 × doubler = 8" + ); + let previous_count = QuantityExpr::Ref { + qty: QuantityRef::PreviousEffectCount, + }; + assert_eq!( + crate::game::quantity::resolve_quantity(&state, &previous_count, PlayerId(0), devour), + 2, + "the direct continuation-local count remains the two selected creatures" + ); + assert_eq!( + crate::game::quantity::resolve_quantity(&state, &event_amount, PlayerId(0), devour), + 1, + "reach-guard: EventContextAmount must retain its outer-event precedence" + ); + + let (mut empty, empty_devour) = + drive_devour_etb_with_battlefield(&face, PlayerId(0), |state| { + battlefield_creature(state, PlayerId(0), "Declined Fodder 0"); + battlefield_creature(state, PlayerId(0), "Declined Fodder 1"); + install_counter_doubler(state, PlayerId(0)); + }); + empty.current_trigger_event = Some(GameEvent::DamageDealt { + source_id: ObjectId(999), + target: crate::types::ability::TargetRef::Player(PlayerId(1)), + amount: 1, + is_combat: false, + excess: 0, + }); + crate::game::engine::apply_as_current(&mut empty, GameAction::SelectCards { cards: vec![] }) + .unwrap(); + + assert_eq!( + p1p1(&empty, empty_devour), + 0, + "an empty Devour choice stays zero despite the outer event" + ); + assert_eq!( + crate::game::quantity::resolve_quantity(&empty, &previous_count, PlayerId(0), empty_devour), + 0, + "the direct continuation-local count records the empty selection" + ); + assert_eq!( + crate::game::quantity::resolve_quantity(&empty, &event_amount, PlayerId(0), empty_devour), + 1, + "reach-guard: the generic event-context ref still sees the outer scalar event" + ); +} + /// P (PRIMARY, the reported bug — Famished Worldsire "Devour land 3", CR 702.82c): /// the ETB sacrifice pool is the controller's LANDS; a co-present creature is /// EXCLUDED. Sacrificing 2 lands to Devour 3 places 3×2 = 6 +1/+1 counters. diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index cf766f08c5..565f641081 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -6927,6 +6927,12 @@ pub enum QuantityRef { #[serde(default, skip_serializing_if = "is_total_damage_channel")] channel: DamageChannel, }, + /// Engine bookkeeping for the immediately preceding resolution-local effect + /// count. This reads `GameState::last_effect_count` directly, defaults an + /// unavailable count to zero, and is not limited to object choices. + /// It preserves the immediate-predecessor relationship while instructions + /// resolve in order (CR 608.2c), so an enclosing event cannot shadow it. + PreviousEffectCount, /// CR 118.4 + CR 119.3: Amount of life lost this turn, scoped by `player` /// per the workspace "Parameterize, don't proliferate" principle (Round Π-3). /// @@ -7454,6 +7460,7 @@ impl QuantityRef { | QuantityRef::TrackedSetAggregate { .. } | QuantityRef::ExiledFromHandThisResolution | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::PreviousEffectCount | QuantityRef::UnspentMana { .. } | QuantityRef::EventContextAmount | QuantityRef::EventContextPlayerCount { .. }