diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 756af306c8..b629ea8a93 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -3113,6 +3113,7 @@ fn record_one_spell_cast_this_turn(state: &mut GameState, player: PlayerId) { from_zone: Zone::Hand, cast_variant: CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }]), ); } @@ -10700,6 +10701,7 @@ fn self_cost_reduction_applies_from_graveyard() { from_zone: Zone::Hand, cast_variant: CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, crate::types::SpellCastRecord { name: "Opt".to_string(), @@ -10713,6 +10715,7 @@ fn self_cost_reduction_applies_from_graveyard() { from_zone: Zone::Hand, cast_variant: CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, ]), ); @@ -27574,6 +27577,7 @@ fn first_qualified_spell_reducer_only_applies_to_first_matching_spell() { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }]), ); @@ -27691,6 +27695,7 @@ fn first_x_spell_reducer_uses_x_filter_dynamic_counter_count_and_first_gate() { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }]), ); @@ -27768,6 +27773,7 @@ fn opponent_first_noncreature_tax_uses_caster_history() { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }]), ); @@ -45470,6 +45476,7 @@ fn convoke_query_before_record_unaffected_by_snapshot() { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }] .into(), ); diff --git a/crates/engine/src/game/enters_with_unless_runtime_tests.rs b/crates/engine/src/game/enters_with_unless_runtime_tests.rs new file mode 100644 index 0000000000..6aed3fc02f --- /dev/null +++ b/crates/engine/src/game/enters_with_unless_runtime_tests.rs @@ -0,0 +1,350 @@ +//! Runtime cast-pipeline coverage for the "enters with [counters] on it unless +//! [game-state condition]" replacement class (CR 614.1c). Built via the +//! `/card-test` recipe: `GameScenario` + `GameRunner::cast(..).resolve()` + +//! `CastOutcome` counter deltas. These discriminate the fix — the primary +//! assertions flip when the silently-dropped " unless " tail is reverted (the +//! counters would then always apply). + +use crate::game::scenario::{GameScenario, P0, P1}; +use crate::types::counter::CounterType; +use crate::types::identifiers::ObjectId; +use crate::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; +use crate::types::phase::Phase; +use crate::types::zones::Zone; + +const HOTHEADED_GIANT: &str = "This creature enters with two -1/-1 counters on it \ + unless you've cast another red spell this turn."; +const STEEL_EXEMPLAR: &str = "This creature enters with two +1/+1 counters on it \ + unless two or more colors of mana were spent to cast it."; + +fn mana(kind: ManaType, n: usize) -> Vec { + vec![ManaUnit::new(kind, ObjectId(0), false, vec![]); n] +} + +/// CR 614.1c (the "enters with …" clause is the replacement) + CR 109.1 (the +/// "another" exclusion basis): a red spell cast earlier this turn suppresses +/// Hotheaded Giant's -1/-1 counters. REVERT DISCRIMINATOR — with the dropped-tail +/// bug the condition is null and the counters always apply (toughness 2), so this +/// `assert_counters(.., 0)` fails on revert. +#[test] +fn hotheaded_giant_suppressed_by_prior_red_spell() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let giant = scenario + .add_creature_to_hand_from_oracle(P0, "Hotheaded Giant", 4, 4, HOTHEADED_GIANT) + .with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Red], + }) + .id(); + scenario.with_mana_pool(P0, mana(ManaType::Red, 1)); + let mut runner = scenario.build(); + + // A DIFFERENT red spell was cast this turn (distinct object id → "another"). + runner.state_mut().spells_cast_this_turn_by_player.insert( + P0, + crate::im::Vector::from(vec![crate::types::game_state::SpellCastRecord { + colors: vec![ManaColor::Red], + spell_object_id: Some(ObjectId(9_999)), + ..Default::default() + }]), + ); + + let outcome = runner.cast(giant).resolve(); + assert_eq!( + outcome.state().objects[&giant].zone, + Zone::Battlefield, + "Giant must resolve onto the battlefield" + ); + assert_eq!( + outcome.counters(giant, CounterType::Minus1Minus1), + 0, + "a prior red spell satisfies the unless clause → no counters" + ); +} + +/// Reach-guard sibling (pairs the test above): when Hotheaded Giant is the only +/// red spell cast this turn, its OWN cast is excluded (CR 400.7 / CR 601.2i), so +/// the unless clause is false and it enters with two -1/-1 counters. Also the +/// runtime own-cast-exclusion discriminator — if the own record were counted the +/// clause would be true and this would wrongly be 0. +#[test] +fn hotheaded_giant_own_cast_not_counted_applies_counters() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let giant = scenario + .add_creature_to_hand_from_oracle(P0, "Hotheaded Giant", 4, 4, HOTHEADED_GIANT) + .with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Red], + }) + .id(); + scenario.with_mana_pool(P0, mana(ManaType::Red, 1)); + let mut runner = scenario.build(); + + let outcome = runner.cast(giant).resolve(); + assert_eq!( + outcome.state().objects[&giant].zone, + Zone::Battlefield, + "Giant must resolve onto the battlefield" + ); + assert_eq!( + outcome.counters(giant, CounterType::Minus1Minus1), + 2, + "Giant's own cast does not count as 'another red spell' → counters apply" + ); +} + +/// CR 106.3 + CR 601.2h: Steel Exemplar cast paying two distinct colors of mana +/// satisfies the unless clause → no +1/+1 counters. REVERT DISCRIMINATOR. +#[test] +fn steel_exemplar_two_colors_spent_suppresses_counters() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let steel = scenario + .add_creature_to_hand_from_oracle(P0, "Steel Exemplar", 2, 2, STEEL_EXEMPLAR) + .with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::White, ManaCostShard::Blue], + }) + .id(); + let mut pool = mana(ManaType::White, 1); + pool.extend(mana(ManaType::Blue, 1)); + scenario.with_mana_pool(P0, pool); + let mut runner = scenario.build(); + + let outcome = runner.cast(steel).resolve(); + assert_eq!( + outcome.state().objects[&steel].zone, + Zone::Battlefield, + "Steel Exemplar must resolve onto the battlefield" + ); + assert_eq!( + outcome.counters(steel, CounterType::Plus1Plus1), + 0, + "two distinct colors spent satisfies the unless clause → no counters" + ); +} + +/// Reach-guard sibling: Steel Exemplar cast paying a single color (one distinct +/// color) does NOT satisfy "two or more colors" → it enters with two +1/+1 +/// counters. +#[test] +fn steel_exemplar_one_color_spent_applies_counters() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let steel = scenario + .add_creature_to_hand_from_oracle(P0, "Steel Exemplar", 2, 2, STEEL_EXEMPLAR) + .with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Red, ManaCostShard::Red], + }) + .id(); + scenario.with_mana_pool(P0, mana(ManaType::Red, 2)); + let mut runner = scenario.build(); + + let outcome = runner.cast(steel).resolve(); + assert_eq!( + outcome.state().objects[&steel].zone, + Zone::Battlefield, + "Steel Exemplar must resolve onto the battlefield" + ); + assert_eq!( + outcome.counters(steel, CounterType::Plus1Plus1), + 2, + "a single distinct color spent leaves the unless clause false → counters apply" + ); +} + +/// Read a permanent's counter count directly from state. Reanimation entries do +/// not flow through `CastOutcome`, so these tests inspect the object. +fn counters_on( + runner: &crate::game::scenario::GameRunner, + obj: ObjectId, + kind: CounterType, +) -> u32 { + runner + .state() + .objects + .get(&obj) + .and_then(|o| o.counters.get(&kind).copied()) + .unwrap_or(0) +} + +/// Put an object onto the battlefield via an effect (reanimation / "put onto the +/// battlefield"), NOT by casting — the entry flows through the replacement +/// pipeline (`object_replacement_candidate_applies`) exactly as a cast entry +/// does, so the "enters with … unless …" self-replacement still applies. +fn reanimate(runner: &mut crate::game::scenario::GameRunner, obj: ObjectId) { + let mut events = Vec::new(); + crate::game::zone_pipeline::move_object( + runner.state_mut(), + crate::game::zone_pipeline::ZoneMoveRequest::effect(obj, Zone::Battlefield, obj), + &mut events, + ); +} + +/// CR 614.1c + CR 109.1: a red spell cast earlier this turn satisfies Hotheaded +/// Giant's unless clause even when the Giant ENTERS BY REANIMATION (a non-cast +/// entry that still routes through `object_replacement_candidate_applies`). The +/// reanimated permanent has no `cast_from_zone` and is not on the stack, so it +/// arms no own-cast exclusion — the foreign red record counts in full → 0 +/// counters. +#[test] +fn hotheaded_giant_reanimated_with_prior_red_spell_suppressed() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let giant = scenario + .add_creature_to_graveyard(P0, "Hotheaded Giant", 4, 4) + .from_oracle_text(HOTHEADED_GIANT) + .id(); + let mut runner = scenario.build(); + + // A DIFFERENT object's red spell was cast this turn. + runner.state_mut().spells_cast_this_turn_by_player.insert( + P0, + crate::im::Vector::from(vec![crate::types::game_state::SpellCastRecord { + colors: vec![ManaColor::Red], + spell_object_id: Some(ObjectId(9_999)), + ..Default::default() + }]), + ); + + reanimate(&mut runner, giant); + assert_eq!( + runner.state().objects[&giant].zone, + Zone::Battlefield, + "Giant must reanimate onto the battlefield" + ); + assert_eq!( + counters_on(&runner, giant, CounterType::Minus1Minus1), + 0, + "a prior red spell satisfies the unless clause on a reanimation entry too" + ); +} + +/// Reach-guard sibling: with NO red spell cast this turn, the reanimated Giant's +/// own entry is not a cast (no spell-history record), so the unless clause is +/// false → it enters with two -1/-1 counters. Discriminates reanimation from a +/// cast: if the entry were miscounted as a cast the clause would flip. +#[test] +fn hotheaded_giant_reanimated_without_red_spell_applies_counters() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let giant = scenario + .add_creature_to_graveyard(P0, "Hotheaded Giant", 4, 4) + .from_oracle_text(HOTHEADED_GIANT) + .id(); + let mut runner = scenario.build(); + + reanimate(&mut runner, giant); + assert_eq!( + runner.state().objects[&giant].zone, + Zone::Battlefield, + "Giant must reanimate onto the battlefield" + ); + assert_eq!( + counters_on(&runner, giant, CounterType::Minus1Minus1), + 2, + "no red spell cast (reanimation is not a cast) → unless clause false → counters apply" + ); +} + +/// CR 614.1c + CR 106.3: a reanimated (un-cast) Steel Exemplar spent no mana, so +/// its `colors_spent_to_cast` is empty and "two or more colors of mana were +/// spent to cast it" is false → it enters with two +1/+1 counters. +#[test] +fn steel_exemplar_reanimated_uncast_applies_counters() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let steel = scenario + .add_creature_to_graveyard(P0, "Steel Exemplar", 2, 2) + .from_oracle_text(STEEL_EXEMPLAR) + .id(); + let mut runner = scenario.build(); + + reanimate(&mut runner, steel); + assert_eq!( + runner.state().objects[&steel].zone, + Zone::Battlefield, + "Steel Exemplar must reanimate onto the battlefield" + ); + assert_eq!( + counters_on(&runner, steel, CounterType::Plus1Plus1), + 2, + "an un-cast entry spent no mana → 'two or more colors' false → counters apply" + ); +} + +// --- "number of OTHER spells you've cast this turn" EFFECT-quantity class --- +// Thunder Salvo (DealDamage) and Lock and Load (Draw) share the SAME own-cast +// exclusion seam as the ETB "unless" replacement: their spell-history filter +// carries `FilterProp::Another`, peels through `peel_own_cast_exclusion`, and +// resolves through the `SpellsCastThisTurn` exclusion arm. Unlike an ETB +// entrant, the resolving instant is still on the STACK and carries NO +// `cast_from_zone`, so it is the direct runtime witness for the on-stack arm. + +const THUNDER_SALVO: &str = "Thunder Salvo deals X damage to target creature, where X is 2 \ + plus the number of other spells you've cast this turn."; + +/// CR 109.1 + CR 608.2n: cast ALONE, Thunder Salvo's own cast is excluded from +/// "other spells you've cast this turn" → X = 2 + 0 = 2 damage. REVERT +/// DISCRIMINATOR for the on-stack own-cast arm: without it the resolving spell +/// (no `cast_from_zone`) counts its own cast → X = 3. +#[test] +fn thunder_salvo_alone_excludes_own_cast() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let target = scenario.add_creature(P1, "Target Wall", 0, 5).id(); + let salvo = scenario + .add_spell_to_hand_from_oracle(P0, "Thunder Salvo", true, THUNDER_SALVO) + .with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Red], + }) + .id(); + scenario.with_mana_pool(P0, mana(ManaType::Red, 1)); + let mut runner = scenario.build(); + + let outcome = runner.cast(salvo).target_object(target).resolve(); + assert_eq!( + outcome.damage_marked(target), + 2, + "cast alone: own cast excluded → X = 2 + 0" + ); +} + +/// Reach-guard sibling: with one OTHER spell already cast this turn (a distinct +/// object), Thunder Salvo deals X = 2 + 1 = 3 — the foreign spell counts and only +/// Thunder Salvo's own cast is excluded. +#[test] +fn thunder_salvo_counts_other_spell_not_own() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let target = scenario.add_creature(P1, "Target Wall", 0, 5).id(); + let salvo = scenario + .add_spell_to_hand_from_oracle(P0, "Thunder Salvo", true, THUNDER_SALVO) + .with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Red], + }) + .id(); + scenario.with_mana_pool(P0, mana(ManaType::Red, 1)); + let mut runner = scenario.build(); + + // One OTHER spell was cast this turn (distinct object id). + runner.state_mut().spells_cast_this_turn_by_player.insert( + P0, + crate::im::Vector::from(vec![crate::types::game_state::SpellCastRecord { + spell_object_id: Some(ObjectId(9_999)), + ..Default::default() + }]), + ); + + let outcome = runner.cast(salvo).target_object(target).resolve(); + assert_eq!( + outcome.damage_marked(target), + 3, + "one other spell counts; Thunder Salvo's own cast is excluded → X = 2 + 1" + ); +} diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index d4c51562f1..dad7ad181d 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -3191,11 +3191,13 @@ struct SpellFilterContext<'a> { source_controller: PlayerId, /// CR 109.1 (cited as identity foundation — CR has no dedicated /// "another" entry): ObjectId of the spell being filtered. `None` when - /// the caller is matching against a historical `SpellCastRecord` - /// (CR 117.x turn-history queries) for which `Another` is structurally - /// indeterminate — those callers fail-closed on `Another`. Live - /// cost-modifier evaluation passes `Some(spell.id)` so "other [X] - /// spells you cast" excludes the static's own source. + /// the caller reaches this helper matching a historical `SpellCastRecord` + /// without provenance (pre-migration snapshots) — those callers fail-closed + /// on `Another`. Live cost-modifier evaluation passes `Some(spell.id)` so + /// "other [X] spells you cast" excludes the static's own source. Turn-history + /// "another" counting now carries provenance via + /// `SpellCastRecord.spell_object_id` and is resolved in `game::quantity`'s + /// own-cast exclusion arm, not through this context. spell_object_id: Option, } @@ -3409,12 +3411,12 @@ fn spell_object_matches_property( // "another" entry): "other [X] spells you cast" excludes the case // where the spell being cast IS the static's own source object. The // check is identity-only (`object_id != source_id`); two distinct - // copies of the same card are NOT "the same" object. Historical- - // record callers pass `spell_object_id: None` and fail-closed here - // (a turn-history "another" query needs the original cast's - // object_id, which is not stored in the snapshot — CR 117.x - // predicates that need it must route through dedicated `Another`- - // aware paths). + // copies of the same card are NOT "the same" object. Live cost-modifier + // callers pass `Some(spell.id)`. The turn-history "another" path now + // lives in `game::quantity` (the `SpellsCastThisTurn` own-cast + // exclusion arm), which reads `SpellCastRecord.spell_object_id` + // provenance directly; snapshot-record callers that reach THIS helper + // still pass `spell_object_id: None` and fail-closed here. FilterProp::Another => context.is_some_and(|ctx| { ctx.spell_object_id .is_some_and(|spell_id| spell_id != ctx.source_id) @@ -7010,6 +7012,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }; let filter = TargetFilter::Typed( TypedFilter::creature() @@ -7124,6 +7127,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }; let non_x_record = SpellCastRecord { has_x_in_cost: false, @@ -7205,6 +7209,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }; let exile_record = SpellCastRecord { from_zone: Zone::Exile, @@ -11468,6 +11473,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, } }; @@ -11957,6 +11963,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }; let dragon_filter = make_subtype_filter("Dragon"); let plains_filter = make_subtype_filter("Plains"); diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 4cd92ae93a..6d49031ce9 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -3128,25 +3128,94 @@ fn resolve_ref( usize_to_i32_saturating(found.len()) } // CR 117.1: Count spells cast this turn by the scoped players, optionally filtered. - QuantityRef::SpellsCastThisTurn { scope, ref filter } => usize_to_i32_saturating( - scoped_players(state, scope, ctx, controller) - .filter_map(|player| state.spells_cast_this_turn_by_player.get(&player.id)) - .map(|list| match filter { - None => list.len(), - Some(filter) => list - .iter() - .filter(|record| { - spell_record_matches_filter( - record, - filter, - controller, - &state.all_creature_types, - ) + QuantityRef::SpellsCastThisTurn { scope, ref filter } => { + // CR 109.1 + CR 614.12: When the filter carries the own-cast + // exclusion marker (`FilterProp::Another`), count records matching + // the PEELED filter, then subtract the source's own pending cast. + // Two production emitters share this marker and this arm, and both + // mean "excluding this object's own cast event": + // - the ETB "enters with … unless you've cast another spell" + // replacement (`with_own_cast_exclusion`, oracle_replacement.rs); + // - the pre-existing "number of OTHER spells you've cast this turn" + // EFFECT quantity (Thunder Salvo's DealDamage amount, Lock and + // Load's draw count), whose parser stamps `FilterProp::Another` + // onto the spell-history filter directly. + // + // CR 400.7: a prior same-id cast record denotes a distinct previous + // object (id is stable storage identity; incarnation bumps on + // battlefield entry) — it counts as "another" spell; only the + // pending cast's own record (the LAST same-id record) is excluded. + let exclusion = filter.as_ref().and_then(|f| f.peel_own_cast_exclusion()); + if let Some(peeled) = exclusion { + // CR 601.2i: the source's own pending cast is excluded in either + // of the two shapes it takes at quantity-resolution time: + // - a resolving instant/sorcery is still on the Stack (its + // effect, e.g. Thunder Salvo, runs before the spell leaves — + // CR 608.2n) and carries NO `cast_from_zone` (that provenance + // is stamped only onto placeholder permanent-spell objects at + // cast and permanents at resolution); + // - an ETB replacement evaluates against the placeholder + // permanent-spell object, which DOES carry `cast_from_zone`. + // A reanimated / put-onto-battlefield permanent is neither on the + // stack nor carries `cast_from_zone` → nothing is excluded. + let source_id = ctx.source; + let own_is_cast = state.objects.get(&source_id).is_some_and(|o| { + o.cast_from_zone.is_some() || o.zone == crate::types::zones::Zone::Stack + }); + let matches = + |record: &crate::types::game_state::SpellCastRecord| match peeled.as_ref() { + None => true, + Some(f) => spell_record_matches_filter( + record, + f, + controller, + &state.all_creature_types, + ), + }; + let total: usize = scoped_players(state, scope, ctx, controller) + .filter_map(|player| state.spells_cast_this_turn_by_player.get(&player.id)) + .map(|list| { + let count = list.iter().filter(|r| matches(r)).count(); + // Per-list own-cast exclusion: `im::Vector` is + // double-ended, push_back order → rev() = most-recent + // first, so the first same-id hit is the pending cast. + if own_is_cast { + if let Some(own) = list + .iter() + .rev() + .find(|r| r.spell_object_id == Some(source_id)) + { + if matches(own) { + return count.saturating_sub(1); + } + } + } + count + }) + .sum(); + usize_to_i32_saturating(total) + } else { + usize_to_i32_saturating( + scoped_players(state, scope, ctx, controller) + .filter_map(|player| state.spells_cast_this_turn_by_player.get(&player.id)) + .map(|list| match filter { + None => list.len(), + Some(filter) => list + .iter() + .filter(|record| { + spell_record_matches_filter( + record, + filter, + controller, + &state.all_creature_types, + ) + }) + .count(), }) - .count(), - }) - .sum(), - ), + .sum(), + ) + } + } // Count permanents matching filter that entered the battlefield this turn. // Uses `entered_battlefield_turn` field on GameObject. QuantityRef::EnteredThisTurn { ref filter } => usize_to_i32_saturating( @@ -10628,6 +10697,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, SpellCastRecord { name: String::new(), @@ -10641,6 +10711,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, ]), ); @@ -10699,6 +10770,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, } } @@ -10739,6 +10811,203 @@ mod tests { ); } + // --- SpellsCastThisTurn own-cast exclusion marker (CR 400.7 / CR 601.2i) --- + + fn red_exclusion_expr() -> QuantityExpr { + let red = TargetFilter::Typed(TypedFilter::card().properties(vec![FilterProp::HasColor { + color: ManaColor::Red, + }])); + QuantityExpr::Ref { + qty: QuantityRef::SpellsCastThisTurn { + scope: CountScope::Controller, + filter: Some(TargetFilter::with_own_cast_exclusion(Some(red))), + }, + } + } + + fn cast_record(color: Option, owner: Option) -> SpellCastRecord { + SpellCastRecord { + colors: color.into_iter().collect(), + spell_object_id: owner, + ..SpellCastRecord::default() + } + } + + /// CR 400.7 / CR 601.2i: the pending cast's own record is identified + /// positionally as the LAST same-id record, and is subtracted ONLY when it + /// itself matches the peeled filter. Hand-built list [R1 own+red (matches), + /// R2 own+colorless (fails filter)] → own = R2 fails filter → subtract + /// nothing → count 1. Flips to 0 under a cap-based "subtract per own record" + /// regression. + #[test] + fn spells_cast_this_turn_own_exclusion_last_record_filter_gated() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(100), + PlayerId(0), + "Hotheaded Giant".to_string(), + Zone::Stack, + ); + // The entering object was itself cast → own exclusion is armed. + state.objects.get_mut(&source).unwrap().cast_from_zone = Some(Zone::Hand); + + state.spells_cast_this_turn_by_player.insert( + PlayerId(0), + crate::im::Vector::from(vec![ + cast_record(Some(ManaColor::Red), Some(source)), + cast_record(None, Some(source)), + ]), + ); + + assert_eq!( + resolve_quantity(&state, &red_exclusion_expr(), PlayerId(0), source), + 1, + "own = last same-id record (colorless) fails the red filter, so nothing is subtracted" + ); + } + + /// CR 400.7: a prior same-id record (a distinct earlier object) DOES count as + /// "another"; only the pending cast's own matching record is excluded. + /// [R_foreign red, R_own red] → count 2, own matches → subtract 1 → 1. + #[test] + fn spells_cast_this_turn_own_excluded_prior_same_id_counts() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(100), + PlayerId(0), + "Hotheaded Giant".to_string(), + Zone::Stack, + ); + state.objects.get_mut(&source).unwrap().cast_from_zone = Some(Zone::Hand); + + state.spells_cast_this_turn_by_player.insert( + PlayerId(0), + crate::im::Vector::from(vec![ + cast_record(Some(ManaColor::Red), Some(ObjectId(999))), + cast_record(Some(ManaColor::Red), Some(source)), + ]), + ); + + assert_eq!( + resolve_quantity(&state, &red_exclusion_expr(), PlayerId(0), source), + 1, + "foreign red counts; only the pending own red cast is excluded" + ); + } + + /// No-identity negative (CR 400.7): a record with `spell_object_id: None` + /// (legacy snapshot) is never identified as own, so it is never excluded. + /// Reach-guard: the `Some(source)` sibling below DOES get excluded. + #[test] + fn spells_cast_this_turn_no_provenance_never_excluded() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(100), + PlayerId(0), + "Hotheaded Giant".to_string(), + Zone::Stack, + ); + state.objects.get_mut(&source).unwrap().cast_from_zone = Some(Zone::Hand); + + // Provenance-less red record → not own → counted. + state.spells_cast_this_turn_by_player.insert( + PlayerId(0), + crate::im::Vector::from(vec![cast_record(Some(ManaColor::Red), None)]), + ); + assert_eq!( + resolve_quantity(&state, &red_exclusion_expr(), PlayerId(0), source), + 1, + "a provenance-less record is never excluded" + ); + + // Reach-guard: the same record carrying provenance IS excluded → 0. + state.spells_cast_this_turn_by_player.insert( + PlayerId(0), + crate::im::Vector::from(vec![cast_record(Some(ManaColor::Red), Some(source))]), + ); + assert_eq!( + resolve_quantity(&state, &red_exclusion_expr(), PlayerId(0), source), + 0, + "the own pending red cast is excluded" + ); + } + + /// CR 400.7: when the entering object was NOT cast (reanimation — + /// `cast_from_zone` is None), no own-cast exclusion is armed, so a prior red + /// record counts in full. + #[test] + fn spells_cast_this_turn_reanimated_source_excludes_nothing() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(100), + PlayerId(0), + "Hotheaded Giant".to_string(), + Zone::Graveyard, + ); + // Reanimated: never cast → cast_from_zone stays None. + assert!(state.objects.get(&source).unwrap().cast_from_zone.is_none()); + + state.spells_cast_this_turn_by_player.insert( + PlayerId(0), + crate::im::Vector::from(vec![cast_record(Some(ManaColor::Red), Some(source))]), + ); + assert_eq!( + resolve_quantity(&state, &red_exclusion_expr(), PlayerId(0), source), + 1, + "reanimated permanent arms no own-cast exclusion" + ); + } + + /// CR 601.2i + CR 608.2n: a resolving instant/sorcery (Thunder Salvo, Lock + /// and Load) whose EFFECT counts "other spells you've cast this turn" is + /// still on the Stack and carries NO `cast_from_zone` (that provenance is + /// stamped only onto placeholder permanent-spell objects), yet its OWN cast + /// record MUST be excluded. REVERT DISCRIMINATOR for the on-stack arm: with + /// only the `cast_from_zone` gate this resolves 1 (own counted); the Stack + /// arm drops it to 0. + #[test] + fn spells_cast_this_turn_on_stack_source_excludes_own() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(100), + PlayerId(0), + "Thunder Salvo".to_string(), + Zone::Stack, + ); + // Instant on the stack: cast_from_zone is never stamped on it. + assert!(state.objects.get(&source).unwrap().cast_from_zone.is_none()); + + state.spells_cast_this_turn_by_player.insert( + PlayerId(0), + crate::im::Vector::from(vec![cast_record(Some(ManaColor::Red), Some(source))]), + ); + assert_eq!( + resolve_quantity(&state, &red_exclusion_expr(), PlayerId(0), source), + 0, + "a resolving on-stack spell excludes its own cast without cast_from_zone" + ); + + // Reach-guard: a foreign prior red record still counts alongside the + // excluded own cast → 1. + state.spells_cast_this_turn_by_player.insert( + PlayerId(0), + crate::im::Vector::from(vec![ + cast_record(Some(ManaColor::Red), Some(ObjectId(999))), + cast_record(Some(ManaColor::Red), Some(source)), + ]), + ); + assert_eq!( + resolve_quantity(&state, &red_exclusion_expr(), PlayerId(0), source), + 1, + "foreign red counts; the on-stack own red cast is excluded" + ); + } + #[test] fn resolve_quantity_lands_played_this_turn_filters_origin_zone() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index e9fc91a967..eafd4eb1ff 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -18503,3 +18503,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "enters_with_unless_runtime_tests.rs"] +mod enters_with_unless_runtime_tests; diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index 00baec5205..fcc586e577 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -213,31 +213,7 @@ pub fn record_spell_cast( ); } -/// CR 117.1 + CR 202.3d + CR 702.102b: The single authority for projecting a -/// spell object into a [`SpellCastRecord`]. Every consumer — spell-cast history -/// (`record_spell_cast_from_zone`), live cost-modifier / cast-prohibition filters -/// (`spell_record_for_restrictions`, `spell_cast_record_from_object`), and -/// per-turn cast-limit filters — routes through here so the spell's mana value and -/// colors come from the split-aware `spell_mana_value`/`spell_colors` authority. A -/// FUSED split spell therefore records the COMBINED value of both halves rather -/// than its front half, so `Cmc`/`HasColor`/`ColorCount`/multicolored filters see -/// the fused spell (CR 709.4d). `spell_mana_value` honors announced X on the stack -/// for non-fused spells (CR 202.3e). -pub(crate) fn spell_cast_record( - obj: &GameObject, - from_zone: Zone, - cast_variant: crate::types::game_state::CastingVariant, -) -> SpellCastRecord { - // CR 702.102b: A spell is fused when the persisted `fused_split_spell` marker - // is set (payment-time / on-stack casts) OR the caller is projecting a - // pre-payment `CastingVariant::Fuse` cast whose marker is not yet set (option - // enumeration / cast preparation on an immutable `&GameState`). Both must - // present the COMBINED characteristics of the two halves. - let fused = cast_variant == crate::types::game_state::CastingVariant::Fuse; - spell_cast_record_for(obj, from_zone, cast_variant, fused) -} - -/// Fuse-aware sibling of [`spell_cast_record`]. `fused_hint` is the caller's +/// The single fuse-aware authority for spell-cast record projection. `fused_hint` is the caller's /// pre-payment determination that the projected spell is a fused split spell /// (CR 702.102b), for seams that know the `CastingVariant::Fuse` intent before the /// `fused_split_spell` marker is set. The effective fused-ness is `fused_hint` OR @@ -271,6 +247,10 @@ pub(crate) fn spell_cast_record_for( cast_variant, // CR 702.33d: Kicker-paid state captured at cast time. was_kicked: !obj.kickers_paid.is_empty(), + // CR 400.7: stable storage id of the cast object — the canonical + // history record carries provenance so own-cast exclusion (CR 601.2i) + // can identify a permanent's own pending cast positionally. + spell_object_id: Some(obj.id), } } @@ -284,7 +264,7 @@ pub fn record_spell_cast_from_zone( state.spells_cast_this_turn = state.spells_cast_this_turn.saturating_add(1); *state.spells_cast_this_game.entry(player).or_insert(0) += 1; // CR 117.1: Record spell characteristics for general-purpose filtered counting. - let record = spell_cast_record(obj, from_zone, cast_variant); + let record = spell_cast_record_for(obj, from_zone, cast_variant, false); state .spells_cast_this_turn_by_player .entry(player) @@ -3246,6 +3226,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }]), ); @@ -3281,6 +3262,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, crate::types::game_state::SpellCastRecord { name: String::new(), @@ -3294,6 +3276,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, crate::types::game_state::SpellCastRecord { name: String::new(), @@ -3307,6 +3290,7 @@ mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, ]), ); diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index e1d3e98ecc..d11c676fe9 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -13336,6 +13336,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }; let current_record = SpellCastRecord { name: String::new(), @@ -13349,6 +13350,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }; state.spells_cast_this_turn_by_player.insert( player, @@ -17561,6 +17563,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, SpellCastRecord { name: String::new(), @@ -17574,6 +17577,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, ]), ); @@ -22414,6 +22418,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }]), ); assert!( @@ -22436,6 +22441,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }]), ); assert!( @@ -22459,6 +22465,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, SpellCastRecord { name: String::new(), @@ -22472,6 +22479,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, ]), ); @@ -22498,6 +22506,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, SpellCastRecord { name: String::new(), @@ -22511,6 +22520,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, ]), ); @@ -22535,6 +22545,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, SpellCastRecord { name: String::new(), @@ -22548,6 +22559,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, SpellCastRecord { name: String::new(), @@ -22561,6 +22573,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }, ]), ); @@ -22585,6 +22598,7 @@ pub mod tests { from_zone: Zone::Hand, cast_variant: crate::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, } } diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index ec97169205..5ac3b7a111 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -655,6 +655,7 @@ fn parse_resolution_context_conditions(input: &str) -> OracleResult<'_, StaticCo parse_source_qualified_mana_spent_condition, parse_source_qualified_mana_spent_threshold, parse_mana_spent_vs_source_pt, + parse_colors_of_mana_spent_threshold, parse_mana_spent_threshold, parse_combat_context_conditions, parse_put_onto_battlefield_this_way, @@ -6304,14 +6305,13 @@ fn parse_source_qualified_mana_spent_condition(input: &str) -> OracleResult<'_, )) } -/// CR 106.3 + CR 601.2h + CR 603.4: Parse -/// "[N] or more mana from was spent to cast " and -/// "at least [N] mana from was spent to cast ". -/// -/// CR 400.7d: the subject anaphora selects the scope (see -/// `parse_source_qualified_mana_spent_condition`). -fn parse_source_qualified_mana_spent_threshold(input: &str) -> OracleResult<'_, StaticCondition> { - let (rest, (n, comparator)) = alt(( +/// Parse the shared amount-threshold prefix that opens several mana-spent +/// conditions: `"at least N"` → `(N, GE)` and `"N or more/fewer/less"` → +/// `(N, GE/LE)`. Factored from the byte-identical closures that previously +/// duplicated this grammar in `parse_source_qualified_mana_spent_threshold` +/// and `parse_mana_spent_threshold`; output is pinned by existing tests. +fn parse_amount_threshold(input: &str) -> OracleResult<'_, (u32, Comparator)> { + alt(( // "at least N " → GE |i| { let (rest, _) = tag("at least ").parse(i)?; @@ -6330,7 +6330,17 @@ fn parse_source_qualified_mana_spent_threshold(input: &str) -> OracleResult<'_, Ok((rest, (n, cmp))) }, )) - .parse(input)?; + .parse(input) +} + +/// CR 106.3 + CR 601.2h + CR 603.4: Parse +/// "[N] or more mana from was spent to cast " and +/// "at least [N] mana from was spent to cast ". +/// +/// CR 400.7d: the subject anaphora selects the scope (see +/// `parse_source_qualified_mana_spent_condition`). +fn parse_source_qualified_mana_spent_threshold(input: &str) -> OracleResult<'_, StaticCondition> { + let (rest, (n, comparator)) = parse_amount_threshold(input)?; let (rest, _) = tag(" mana from ").parse(rest)?; let (rest, source_filter) = nom_quantity::parse_mana_source_filter(rest)?; let (rest, _) = tag(" was spent to cast ").parse(rest)?; @@ -6471,26 +6481,7 @@ fn parse_mana_spent_threshold(input: &str) -> OracleResult<'_, StaticCondition> // "N or more mana was spent to cast …" // "at least N mana was spent to cast …" // Plus the inverse: "N or less/fewer mana was spent to cast …" - let (rest, (n, comparator)) = alt(( - // "at least N " → GE - |i| { - let (rest, _) = tag("at least ").parse(i)?; - let (rest, n) = parse_number(rest)?; - Ok((rest, (n, Comparator::GE))) - }, - // "N or more/less/fewer" - |i| { - let (rest, n) = parse_number(i)?; - let (rest, cmp) = alt(( - value(Comparator::GE, tag(" or more")), - value(Comparator::LE, tag(" or fewer")), - value(Comparator::LE, tag(" or less")), - )) - .parse(rest)?; - Ok((rest, (n, cmp))) - }, - )) - .parse(input)?; + let (rest, (n, comparator)) = parse_amount_threshold(input)?; // Fixed tail: " mana was spent to cast " + subject anaphora. let (rest, _) = tag(" mana was spent to cast ").parse(rest)?; let (rest, scope) = nom_quantity::parse_mana_spent_self_subject(rest)?; @@ -6509,6 +6500,43 @@ fn parse_mana_spent_threshold(input: &str) -> OracleResult<'_, StaticCondition> )) } +/// CR 106.3 + CR 601.2h: Parse "[N] or more colors of mana were spent to cast +/// " and the singular "one color of mana was spent to cast " +/// (Steel Exemplar's "unless two or more colors of mana were spent to cast +/// it"). Measures `CastManaSpentMetric::DistinctColors` — how many distinct +/// colors of mana paid the cost — against a fixed threshold. CR 400.7d: the +/// subject anaphora selects the scope (mirrors `parse_mana_spent_threshold`). +fn parse_colors_of_mana_spent_threshold(input: &str) -> OracleResult<'_, StaticCondition> { + // "two or more colors" / "at least two colors" carry an explicit comparator; + // the singular "one color" is a bare count that reads as `>= 1` (any colored + // mana spent). The comparator branch is tried first so "N or more" is not + // consumed as a bare N. + let (rest, (n, comparator)) = alt(( + parse_amount_threshold, + map(parse_number, |n| (n, Comparator::GE)), + )) + .parse(input)?; + // "color" / "colors" — singular ("one color") and plural ("two or more colors"). + let (rest, _) = (tag(" color"), opt(tag("s"))).parse(rest)?; + let (rest, _) = tag(" of mana ").parse(rest)?; + let (rest, _) = alt((tag("were"), tag("was"))).parse(rest)?; + let (rest, _) = tag(" spent to cast ").parse(rest)?; + let (rest, scope) = nom_quantity::parse_mana_spent_self_subject(rest)?; + Ok(( + rest, + StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope, + metric: CastManaSpentMetric::DistinctColors, + }, + }, + comparator, + rhs: QuantityExpr::Fixed { value: n as i32 }, + }, + )) +} + /// CR 509.1b + CR 506.5: Parse combat-context conditions. /// /// Handles "defending player controls a/an [type]" and "it's attacking alone". @@ -7155,18 +7183,15 @@ fn parse_another_spell_cast_this_turn( parse_another_spell_this_turn(rest, minimum) } -fn parse_another_spell_this_turn(input: &str, minimum: u32) -> OracleResult<'_, StaticCondition> { +/// Parse the tail that follows "cast another " / "you('ve) cast another ": +/// `"spell this turn"` → `None` (any spell); `" spell this turn"` → +/// `Some(filter)` via `parse_spell_history_filter`. Shared by +/// `parse_another_spell_this_turn` (trigger/GE-N context) and +/// `parse_you_cast_another_spell_filter_this_turn` (replacement "unless" +/// context) so both derive the "another" spell-history filter identically. +fn parse_another_spell_tail(input: &str) -> OracleResult<'_, Option> { if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("spell this turn").parse(input) { - return Ok(( - rest, - make_quantity_ge( - QuantityRef::SpellsCastThisTurn { - scope: CountScope::Controller, - filter: None, - }, - minimum, - ), - )); + return Ok((rest, None)); } let (rest, type_text) = take_until(" this turn").parse(input)?; let (rest, _) = tag(" this turn").parse(rest)?; @@ -7176,18 +7201,39 @@ fn parse_another_spell_this_turn(input: &str, minimum: u32) -> OracleResult<'_, nom::error::ErrorKind::Fail, ))); }; + Ok((rest, Some(filter))) +} + +fn parse_another_spell_this_turn(input: &str, minimum: u32) -> OracleResult<'_, StaticCondition> { + let (rest, filter) = parse_another_spell_tail(input)?; Ok(( rest, make_quantity_ge( QuantityRef::SpellsCastThisTurn { scope: CountScope::Controller, - filter: Some(filter), + filter, }, minimum, ), )) } +/// CR 614.1c (replacement classification) + CR 109.1 (identity basis for +/// "another"): Parse "you('ve) cast another [] spell this turn" and +/// return the controller-scoped spell-history filter (`None` = any spell). +/// Used by the ETB "unless" replacement parser to build the own-cast-excluding +/// `SpellsCastThisTurn` reference; the exclusion marker is injected by the +/// caller via `TargetFilter::with_own_cast_exclusion`. +pub(crate) fn parse_you_cast_another_spell_filter_this_turn( + input: &str, +) -> OracleResult<'_, Option> { + preceded( + alt((tag("you cast another "), tag("you've cast another "))), + parse_another_spell_tail, + ) + .parse(input) +} + fn parse_spell_history_filter_with_optional_article(type_text: &str) -> Option { let trimmed = type_text.trim(); let filter_text = parse_article(trimmed) @@ -16066,6 +16112,100 @@ mod tests { } } + /// CR 106.3 + CR 601.2h: "two or more colors of mana were spent to cast it" + /// → DistinctColors GE 2 (Steel Exemplar's unless clause). + #[test] + fn colors_of_mana_spent_threshold_two_or_more() { + let (rest, c) = + parse_inner_condition("two or more colors of mana were spent to cast it").unwrap(); + assert_eq!(rest, ""); + match c { + StaticCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::ManaSpentToCast { + scope, + metric: CastManaSpentMetric::DistinctColors, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 2 }, + } => assert_eq!(scope, CastManaObjectScope::SelfObject), + other => panic!("expected DistinctColors GE 2, got {other:?}"), + } + } + + /// CR 106.3: singular "one color of mana was spent to cast it" → DistinctColors GE 1. + #[test] + fn colors_of_mana_spent_threshold_singular() { + let (rest, c) = parse_inner_condition("one color of mana was spent to cast it").unwrap(); + assert_eq!(rest, ""); + assert!(matches!( + c, + StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope: CastManaObjectScope::SelfObject, + metric: CastManaSpentMetric::DistinctColors, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + } + )); + } + + /// CR 400.7d: the subject anaphora "that spell" selects `TriggeringSpell` scope. + #[test] + fn colors_of_mana_spent_threshold_that_spell_scope() { + let (rest, c) = + parse_inner_condition("two or more colors of mana were spent to cast that spell") + .unwrap(); + assert_eq!(rest, ""); + assert!(matches!( + c, + StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + scope: CastManaObjectScope::TriggeringSpell, + metric: CastManaSpentMetric::DistinctColors, + }, + }, + .. + } + )); + } + + /// Negative sibling: "spent to activate this ability" must NOT parse via the + /// colors-spent-to-cast arm (it lacks the "spent to cast " tail). + /// Reach-guard: the parseable "…to cast it" sibling in + /// `colors_of_mana_spent_threshold_singular` proves the arm is live. + #[test] + fn colors_of_mana_spent_threshold_rejects_activate_ability() { + let parsed = + parse_inner_condition("two or more colors of mana were spent to activate this ability"); + let is_colors_metric = matches!( + parsed, + Ok(( + _, + StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ManaSpentToCast { + metric: CastManaSpentMetric::DistinctColors, + .. + }, + }, + .. + } + )) + ); + assert!( + !is_colors_metric, + "activate-ability text must not parse as colors-spent-to-cast" + ); + } + #[test] fn test_parse_condition_mana_spent_vs_self_power_only() { let (rest, c) = parse_condition( diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 76d8d31e57..34c152300e 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -19,7 +19,7 @@ use super::oracle_ir::replacement::ReplacementIr; use super::oracle_nom::bridge::{nom_on_lower, nom_parse_lower, split_once_on_lower}; use super::oracle_nom::condition::{ parse_attached_subject_target_filter, parse_inner_condition, - parse_opponent_who_controls_at_least_as_many, + parse_opponent_who_controls_at_least_as_many, parse_you_cast_another_spell_filter_this_turn, }; use super::oracle_nom::duration::parse_duration; use super::oracle_nom::primitives as nom_primitives; @@ -34,7 +34,7 @@ use super::oracle_util::{ use crate::types::ability::CastingPermission; use crate::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, CastVariantPaid, ChoiceType, CombatDamageScope, - Comparator, ContinuousModification, ControllerRef, CopyManaValueLimit, + Comparator, ContinuousModification, ControllerRef, CopyManaValueLimit, CountScope, CounterReplacementSubject, DamageModification, DamageRedirectTarget, DamageTargetFilter, DamageTargetPlayerScope, DrawReplacementScope, Duration, Effect, EffectScope, FilterProp, LibraryPosition, ManaModification, ManaReplacementScope, ManaSpendPermission, @@ -3854,6 +3854,13 @@ fn parse_enters_with_counters( // CR 702.33d: kicker condition gates the replacement effect. let (kicker_condition, work_text) = extract_kicker_enters_condition(norm_lower); + // CR 614.1c: Split any trailing " unless " gate off + // the payload UP FRONT, so all payload parsing below (escape scan, "with " + // split, choice-of-counter, dynamic quantities, enters-tapped, distributive + // subject) operates on the unless-free text. Silently dropping this tail is + // the bug class being fixed (Hotheaded Giant / Steel Exemplar). + let (work_text, unless_outcome) = extract_enters_with_unless_suffix(work_text); + // CR 702.138c: "escapes with" / plural-subject "escape with" is // semantically "enters with" gated on escape. let is_escape = nom_primitives::scan_contains(work_text, "escapes with") @@ -3937,22 +3944,15 @@ fn parse_enters_with_counters( .destination_zone(Zone::Battlefield) .description(original_text.to_string()); - // Reuse the existing condition tail (escape / kicker / cast-from-zone - // / raid / web-slinging / generic only-if). - if is_escape { - def = def.condition(ReplacementCondition::CastViaEscape); - } else if let Some(cond) = kicker_condition { - def = def.condition(cond); - } else if let Some(zone) = extract_cast_from_zone_suffix(work_text) { - def = def.condition(ReplacementCondition::CastFromZone { zone }); - } else if extract_you_attacked_this_turn_suffix(work_text) { - def = def.condition(ReplacementCondition::YouAttackedThisTurn); - } else if extract_cast_using_web_slinging_suffix(work_text) { - def = def.condition(ReplacementCondition::CastVariantPaid { - variant: CastVariantPaid::WebSlinging, - }); - } else if let Some(condition) = extract_enters_with_only_if_suffix(work_text) { - def = def.condition(condition); + // CR 614.1c: Attach the single applicable gate. The " unless " gate + // and the trailing conditional-suffix gate are mutually exclusive — + // one condition slot — so their co-occurrence fails closed. + let other_suffix = + enters_with_condition_suffix(is_escape, &kicker_condition, work_text); + match resolve_enters_with_condition(&unless_outcome, other_suffix) { + None => return None, + Some(Some(cond)) => def = def.condition(cond), + Some(None) => {} } return Some(def); @@ -4130,31 +4130,72 @@ fn parse_enters_with_counters( def = def.valid_card(filter); } - // Apply condition: escape, kicker, or cast-from-zone suffix. - // CR 603.4: Myojin-class "enters with [counter] on it if you cast it - // from your hand" — trailing zone gate on a self-ETB replacement. + // Apply condition: escape, kicker, cast-from-zone/raid/web-slinging/only-if + // suffix, OR the up-front " unless " gate. CR 603.4: Myojin-class "enters + // with [counter] on it if you cast it from your hand". CR 614.1c: the + // " unless " gate and a trailing conditional-suffix gate share the single + // condition slot, so their co-occurrence fails closed (→ unimplemented) + // rather than silently dropping one gate. + let other_suffix = enters_with_condition_suffix(is_escape, &kicker_condition, work_text); + match resolve_enters_with_condition(&unless_outcome, other_suffix) { + None => return None, + Some(Some(cond)) => def = def.condition(cond), + Some(None) => {} + } + + Some(def) +} + +/// CR 614.1c: The trailing conditional-suffix gate shared by both the +/// choice-of-counter branch and the main single-counter branch of +/// `parse_enters_with_counters` — escape / kicker / cast-from-zone / raid / +/// web-slinging / generic only-if, in that precedence order. Factored so the +/// two branches cannot drift. +fn enters_with_condition_suffix( + is_escape: bool, + kicker_condition: &Option, + work_text: &str, +) -> Option { if is_escape { - def = def.condition(ReplacementCondition::CastViaEscape); + // CR 702.138c + Some(ReplacementCondition::CastViaEscape) } else if let Some(cond) = kicker_condition { - def = def.condition(cond); + // CR 702.33d + Some(cond.clone()) } else if let Some(zone) = extract_cast_from_zone_suffix(work_text) { - def = def.condition(ReplacementCondition::CastFromZone { zone }); + // CR 603.4 + Some(ReplacementCondition::CastFromZone { zone }) } else if extract_you_attacked_this_turn_suffix(work_text) { - // CR 207.2c (Raid): "Raid — ~ enters with [counter] on it if you - // attacked this turn." (Cruel Administrator, Goblin Boarders, etc.) - def = def.condition(ReplacementCondition::YouAttackedThisTurn); + // CR 207.2c (Raid) + Some(ReplacementCondition::YouAttackedThisTurn) } else if extract_cast_using_web_slinging_suffix(work_text) { - // CR 702.188a: "If ~ was cast using web-slinging, ..." (Scarlet Spider). - def = def.condition(ReplacementCondition::CastVariantPaid { + // CR 702.188a + Some(ReplacementCondition::CastVariantPaid { variant: CastVariantPaid::WebSlinging, - }); - } else if let Some(condition) = extract_enters_with_only_if_suffix(work_text) { - // CR 614.1c + CR 700.4: Generic suffix gates for ETB-counter - // replacements, e.g. Morbid's "if a creature died this turn". - def = def.condition(condition); + }) + } else { + // CR 614.1c + CR 700.4: generic only-if suffix, or no gate. + extract_enters_with_only_if_suffix(work_text) } +} - Some(def) +/// CR 614.1c: Reconcile the up-front " unless " gate with the trailing +/// conditional-suffix gate. `None` = fail closed (the caller returns `None` so +/// the line falls through to `Effect::unimplemented`): either the unless clause +/// was present-but-unparsed, or both gates co-occur and there is only one +/// condition slot. `Some(None)` = no gate. `Some(Some(cond))` = the single +/// applicable gate. +fn resolve_enters_with_condition( + unless_outcome: &EntersWithUnlessOutcome, + other_suffix: Option, +) -> Option> { + match (unless_outcome, other_suffix) { + (EntersWithUnlessOutcome::Unparsed, _) => None, + (EntersWithUnlessOutcome::Parsed(_), Some(_)) => None, + (EntersWithUnlessOutcome::Parsed(cond), None) => Some(Some(cond.clone())), + (EntersWithUnlessOutcome::NoUnlessClause, Some(other)) => Some(Some(other)), + (EntersWithUnlessOutcome::NoUnlessClause, None) => Some(None), + } } fn has_enters_tapped_with_counter(text: &str) -> bool { @@ -4216,6 +4257,108 @@ fn extract_enters_with_only_if_suffix(text: &str) -> Option" gate (CR 614.1c). +#[derive(Debug, Clone)] +enum EntersWithUnlessOutcome { + /// No " unless " clause present — the payload has no unless gate. + NoUnlessClause, + /// A recognized game-state condition; the replacement is suppressed while + /// the condition holds. + Parsed(ReplacementCondition), + /// A " unless " clause is present but its condition could not be parsed — + /// the caller MUST fail closed (never silently drop the gate, the bug class + /// this fixes) so the line falls through to `Effect::unimplemented`. + Unparsed, +} + +/// CR 614.1c: Split a trailing " unless " gate off an +/// "enters with [counter payload] on it" clause and classify the condition. +/// Returns the unless-free head plus the outcome. Routing order matters: the +/// "you've cast another spell" route must precede the generic static-condition +/// route, because the generic route would parse the same tail as a plain +/// `SpellsCastThisTurn >= 2` WITHOUT the own-cast exclusion marker that +/// "another" requires (per Gatherer ruling — a permanent's own cast does not +/// count as "another spell"). +fn extract_enters_with_unless_suffix(text: &str) -> (&str, EntersWithUnlessOutcome) { + let (head, tail) = match nom_primitives::split_once_on(text, " unless ") { + Ok((_, (before, after))) => (before, after), + Err(_) => return (text, EntersWithUnlessOutcome::NoUnlessClause), + }; + let unless_text = tail.trim().trim_end_matches('.'); + + // Route 1: "unless you('ve) cast another [] spell this turn". + // CR 614.1c classifies the "enters with …" clause as the replacement; the + // "another" exclusion basis is CR 109.1 (identity): the entering permanent's + // own cast must be excluded, so the filter carries the identity marker via + // `with_own_cast_exclusion` and the threshold is GE 1 (one OTHER matching + // spell). + if let Ok((rest, filter)) = parse_you_cast_another_spell_filter_this_turn(unless_text) { + if rest.trim().is_empty() { + return ( + head, + EntersWithUnlessOutcome::Parsed(ReplacementCondition::UnlessQuantity { + lhs: QuantityExpr::Ref { + qty: QuantityRef::SpellsCastThisTurn { + scope: CountScope::Controller, + filter: Some(TargetFilter::with_own_cast_exclusion(filter)), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + active_player_req: None, + }), + ); + } + } + + // Route 2: any other game-state condition recognized by the shared + // condition grammar (e.g. Steel Exemplar's "two or more colors of mana + // were spent to cast it"). + if let Ok((rest, condition)) = parse_inner_condition(unless_text) { + if rest.trim().is_empty() { + if let Some(cond) = replacement_condition_from_static_unless(condition) { + return (head, EntersWithUnlessOutcome::Parsed(cond)); + } + } + } + + (head, EntersWithUnlessOutcome::Unparsed) +} + +/// CR 614.1c + CR 614.1d: Map a parsed `StaticCondition` to the `unless`-polarity +/// `ReplacementCondition`. Unlike `replacement_condition_from_static` (the +/// only-if polarity used by "enters with ... if ..."), an "unless" clause +/// suppresses the replacement while the condition holds, so the mapping is +/// faithful (NO negation) — the runtime `UnlessQuantity` arm already inverts. +fn replacement_condition_from_static_unless( + condition: StaticCondition, +) -> Option { + match condition { + StaticCondition::QuantityComparison { + lhs, + comparator, + rhs, + } => Some(ReplacementCondition::UnlessQuantity { + lhs, + comparator, + rhs, + active_player_req: None, + }), + // "unless ~ is tapped" → replacement applies while the source is untapped. + StaticCondition::SourceIsTapped => { + Some(ReplacementCondition::SourceTappedState { tapped: false }) + } + // "unless [not X]" is a double negative — the replacement applies while + // X holds, i.e. the only-if mapping of X. The polarity guard: never let + // an inner condition fold into an `UnlessPay`-style cost (see + // `parse_unless_pay_condition` in condition.rs) — `replacement_condition_from_static` + // cannot produce one, so delegation is safe. + StaticCondition::Not { condition } => replacement_condition_from_static(*condition), + _ => None, + } +} + fn parse_enters_counter_for_each_suffix(after_counter: &str) -> Option { let (rest, _) = opt(tag::<_, _, OracleError<'_>>("s")) .parse(after_counter) @@ -11678,6 +11821,197 @@ mod tests { ); } + // --- "enters with [counters] on it unless [condition]" (CR 614.1c) --- + + /// SHAPE (CR 614.1c + CR 614.1d): Hotheaded Giant's verbatim line lowers to a + /// Moved/Battlefield replacement whose PutCounter payload is two -1/-1 + /// counters, gated by `UnlessQuantity` counting controller spells this turn + /// filtered to red WITH the own-cast exclusion marker (`FilterProp::Another`), + /// threshold GE 1. Asserting the marker via a semantic accessor + /// (`peel_own_cast_exclusion`), not a raw internal flag. + #[test] + fn hotheaded_giant_enters_with_unless_another_red_spell() { + use crate::types::ability::{Effect, FilterProp}; + use crate::types::counter::CounterType; + use crate::types::mana::ManaColor; + + let def = parse_replacement_line( + "This creature enters with two -1/-1 counters on it unless you've cast \ + another red spell this turn.", + "Hotheaded Giant", + ) + .expect("enters-with-counters-unless must parse"); + + assert_eq!(def.event, ReplacementEvent::Moved); + assert_eq!(def.valid_card, Some(TargetFilter::SelfRef)); + + let execute = def.execute.expect("PutCounter payload"); + match &*execute.effect { + Effect::PutCounter { + counter_type, + count, + .. + } => { + assert_eq!(*counter_type, CounterType::Minus1Minus1); + assert_eq!(*count, QuantityExpr::Fixed { value: 2 }); + } + other => panic!("expected PutCounter, got {other:?}"), + } + + match def.condition.expect("unless gate") { + ReplacementCondition::UnlessQuantity { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::SpellsCastThisTurn { + scope: CountScope::Controller, + filter: Some(filter), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + active_player_req: None, + } => { + // Marker present → peel yields the red-only filter for matching. + let peeled = filter + .peel_own_cast_exclusion() + .expect("own-cast exclusion marker must be present"); + let red = peeled.expect("peeled filter is the red constraint"); + assert!( + matches!( + &red, + TargetFilter::Typed(t) + if t.properties.contains(&FilterProp::HasColor { color: ManaColor::Red }) + && !t.properties.contains(&FilterProp::Another) + ), + "peeled filter must be red without the marker, got {red:?}" + ); + } + other => panic!("expected UnlessQuantity red+Another GE 1, got {other:?}"), + } + } + + /// SHAPE (CR 106.3 + CR 601.2h): Steel Exemplar's verbatim line gates the + /// +1/+1 payload on `UnlessQuantity` over the distinct colors of mana spent + /// to cast it, threshold GE 2. + #[test] + fn steel_exemplar_enters_with_unless_two_colors_spent() { + use crate::types::ability::{CastManaObjectScope, CastManaSpentMetric, Effect}; + use crate::types::counter::CounterType; + + let def = parse_replacement_line( + "This creature enters with two +1/+1 counters on it unless two or more \ + colors of mana were spent to cast it.", + "Steel Exemplar", + ) + .expect("colors-of-mana unless must parse"); + + let execute = def.execute.expect("PutCounter payload"); + match &*execute.effect { + Effect::PutCounter { + counter_type, + count, + .. + } => { + assert_eq!(*counter_type, CounterType::Plus1Plus1); + assert_eq!(*count, QuantityExpr::Fixed { value: 2 }); + } + other => panic!("expected PutCounter, got {other:?}"), + } + + match def.condition.expect("unless gate") { + ReplacementCondition::UnlessQuantity { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::ManaSpentToCast { + scope: CastManaObjectScope::SelfObject, + metric: CastManaSpentMetric::DistinctColors, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 2 }, + active_player_req: None, + } => {} + other => panic!("expected UnlessQuantity DistinctColors GE 2, got {other:?}"), + } + } + + /// CR 614.1c: an unrecognized " unless " condition must FAIL CLOSED (parse to + /// None → `Effect::unimplemented` upstream), never silently drop the gate and + /// apply the counters unconditionally (the bug class). Positive reach-guard: + /// the same payload with a parseable gate DOES parse (see + /// `hotheaded_giant_enters_with_unless_another_red_spell`). + #[test] + fn enters_with_unless_unparsed_condition_fails_closed() { + let def = parse_replacement_line( + "This creature enters with two -1/-1 counters on it unless the moon is full.", + "Fake Card", + ); + assert!( + def.is_none(), + "unparsed unless clause must fail closed, got {def:?}" + ); + } + + /// CR 614.1c: the " unless " gate and a trailing conditional-suffix gate share + /// ONE condition slot, so their co-occurrence fails closed. Reach-guards: the + /// kicker-only and unless-only siblings each parse on their own. + #[test] + fn enters_with_kicker_and_unless_co_occurrence_fails_closed() { + let both = parse_replacement_line( + "If this creature was kicked, it enters with a +1/+1 counter on it \ + unless you've cast another spell this turn.", + "Fake Kicker Card", + ); + assert!( + both.is_none(), + "kicker + unless co-occurrence must fail closed, got {both:?}" + ); + + // Reach-guard 1: kicker-only path is live. + let kicker_only = parse_replacement_line( + "If this creature was kicked, it enters with a +1/+1 counter on it.", + "Fake Kicker Card", + ); + assert!(kicker_only.is_some(), "kicker-only path must parse"); + + // Reach-guard 2: unless-only path is live. + let unless_only = parse_replacement_line( + "This creature enters with a +1/+1 counter on it unless you've cast \ + another spell this turn.", + "Fake Unless Card", + ); + assert!(unless_only.is_some(), "unless-only path must parse"); + } + + /// CR 614.12a + CR 614.1c: the shared condition-suffix helper guards BOTH the + /// single-counter branch and the choice-of-counter branch — the co-occurrence + /// fail-closed holds through the `your choice of` branch too. + #[test] + fn enters_with_choice_kicker_and_unless_co_occurrence_fails_closed() { + let both = parse_replacement_line( + "If this creature was kicked, it enters with your choice of a +1/+1 or a \ + -1/-1 counter on it unless you've cast another spell this turn.", + "Fake Choice Card", + ); + assert!( + both.is_none(), + "choice-branch kicker + unless co-occurrence must fail closed, got {both:?}" + ); + + // Reach-guard: the choice-branch unless-only path is live. + let unless_only = parse_replacement_line( + "This creature enters with your choice of a +1/+1 or a -1/-1 counter on it \ + unless you've cast another spell this turn.", + "Fake Choice Card", + ); + assert!( + unless_only.is_some(), + "choice-branch unless-only path must parse" + ); + } + #[test] fn turned_face_up_replacement_gaps_external_target_choice() { // CR 708.11: an "As ~ is turned face up" effect applies during the diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 9ecd53719b..41cb7523c9 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -13579,6 +13579,112 @@ impl TargetFilter { } } + /// CR 109.1: Inject the own-cast exclusion marker (`FilterProp::Another`) + /// into a spell-history filter so the `SpellsCastThisTurn` resolver can + /// exclude the source's *own* pending cast when counting "another spell". + /// The marker is identity-only and is consumed by the `SpellsCastThisTurn` + /// own-cast exclusion arm (via `peel_own_cast_exclusion`). This constructor + /// is the ETB "enters with … unless you've cast another spell" emitter, but + /// it is NOT the only producer of an `Another`-bearing spell-history filter: + /// the pre-existing "number of OTHER spells you've cast this turn" EFFECT + /// quantity (Thunder Salvo, Lock and Load) stamps `Another` onto its + /// spell-history filter directly via the parser's `"other"` article. Both + /// carry the SAME unified semantics — "excluding this object's own cast + /// event" — and both resolve through that one arm. `inner` is the parsed + /// spell-type filter + /// (`None` = any spell). A `Typed` inner gets the marker prepended; a + /// non-`Typed` inner (e.g. an `Or` of colors) is AND-wrapped with a bare + /// marker leg; `None` yields a bare card+marker filter. + pub fn with_own_cast_exclusion(inner: Option) -> TargetFilter { + match inner { + None => TargetFilter::Typed(TypedFilter::card().properties(vec![FilterProp::Another])), + Some(TargetFilter::Typed(mut typed)) => { + if !typed.properties.contains(&FilterProp::Another) { + typed.properties.insert(0, FilterProp::Another); + } + TargetFilter::Typed(typed) + } + Some(other) => TargetFilter::And { + filters: vec![ + TargetFilter::Typed(TypedFilter::card().properties(vec![FilterProp::Another])), + other, + ], + }, + } + } + + /// CR 109.1: Dual of `with_own_cast_exclusion`. If this filter carries the + /// own-cast exclusion marker, return `Some(peeled)` where `peeled` is the + /// filter to match records against with the marker removed (`None` = match + /// every spell). Returns the outer `None` when the marker is absent. The + /// peel MUST precede record matching because `spell_record_matches_filter` + /// fail-closes on `FilterProp::Another`. + /// + /// Three marker-bearing shapes are recognized, covering both emitters: + /// - `Typed` with the marker among its properties (ETB "another red spell", + /// Thunder Salvo's `Typed[Card, Another]` → residual `None`); + /// - `And` with a bare-marker leg (`with_own_cast_exclusion` on a non-Typed + /// inner); + /// - `Or` whose EVERY disjunct is a `Typed` carrying the marker (Lock and + /// Load's "other instant and sorcery spell you've cast this turn", where + /// the `"other"` article stamps `Another` onto each leg) → residual `Or` + /// of the leg type filters. + pub fn peel_own_cast_exclusion(&self) -> Option> { + let bare_marker = + TargetFilter::Typed(TypedFilter::card().properties(vec![FilterProp::Another])); + match self { + TargetFilter::Typed(typed) if typed.properties.contains(&FilterProp::Another) => { + let mut peeled = typed.clone(); + peeled.properties.retain(|p| p != &FilterProp::Another); + if peeled.properties.is_empty() + && peeled.controller.is_none() + && peeled.type_filters == vec![TypeFilter::Card] + { + Some(None) + } else { + Some(Some(TargetFilter::Typed(peeled))) + } + } + TargetFilter::And { filters } => { + let marker_pos = filters.iter().position(|filter| filter == &bare_marker)?; + let mut rest = filters.clone(); + rest.remove(marker_pos); + match rest.len() { + 0 => Some(None), + 1 => Some(Some(rest.into_iter().next().expect("len checked"))), + _ => Some(Some(TargetFilter::And { filters: rest })), + } + } + // "other [A] and [B] spells you've cast this turn" (Lock and Load): + // the `"other"` article stamps `FilterProp::Another` onto EVERY + // disjunct rather than producing a single bare-marker leg. Peel the + // marker from each leg and return the residual `Or` of type filters. + TargetFilter::Or { filters } + if !filters.is_empty() + && filters.iter().all(|f| { + matches!(f, TargetFilter::Typed(t) + if t.properties.contains(&FilterProp::Another)) + }) => + { + let peeled_legs = filters + .iter() + .map(|f| { + let TargetFilter::Typed(t) = f else { + unreachable!("all-Typed legs checked in guard") + }; + let mut t = t.clone(); + t.properties.retain(|p| p != &FilterProp::Another); + TargetFilter::Typed(t) + }) + .collect(); + Some(Some(TargetFilter::Or { + filters: peeled_legs, + })) + } + _ => None, + } + } + pub fn normalized(self) -> Self { match self { TargetFilter::Typed(filter) => TargetFilter::Typed(filter.normalized()), @@ -21786,6 +21892,86 @@ mod tests { use crate::types::mana::ZoneSpendPolarity; use crate::types::zones::Zone; + /// CR 109.1: `with_own_cast_exclusion` injects the `Another` marker and + /// `peel_own_cast_exclusion` is its exact dual across the three inner shapes + /// (Typed, non-Typed, None). The peel MUST remove the marker so downstream + /// record matching (which fail-closes on `Another`) sees the residual filter. + #[test] + fn own_cast_exclusion_roundtrips_across_inner_shapes() { + // Typed inner: marker prepended; peel yields the red-only residual. + let red = TargetFilter::Typed(TypedFilter::card().properties(vec![FilterProp::HasColor { + color: crate::types::mana::ManaColor::Red, + }])); + let wrapped = TargetFilter::with_own_cast_exclusion(Some(red.clone())); + assert!(matches!( + &wrapped, + TargetFilter::Typed(t) if t.properties.contains(&FilterProp::Another) + )); + assert_eq!( + wrapped.peel_own_cast_exclusion(), + Some(Some(red)), + "Typed inner peels back to the residual red filter" + ); + + // None inner: bare marker; peel yields None (matches every spell). + let any = TargetFilter::with_own_cast_exclusion(None); + assert_eq!(any.peel_own_cast_exclusion(), Some(None)); + + // Non-Typed inner (Or): AND-wrapped with a marker leg; peel yields the Or. + let or_inner = TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter::new(TypeFilter::Instant)), + TargetFilter::Typed(TypedFilter::new(TypeFilter::Sorcery)), + ], + }; + let wrapped_or = TargetFilter::with_own_cast_exclusion(Some(or_inner.clone())); + assert_eq!(wrapped_or.peel_own_cast_exclusion(), Some(Some(or_inner))); + + // No marker → peel returns None (outer). + assert_eq!( + TargetFilter::Typed(TypedFilter::creature()).peel_own_cast_exclusion(), + None + ); + } + + /// CR 109.1: the EFFECT-quantity emitter ("number of OTHER instant and + /// sorcery spell you've cast this turn", Lock and Load) stamps `Another` onto + /// EVERY `Or` leg rather than producing a bare-marker leg. `peel` must strip + /// the marker from each leg and return the residual `Or` so the resolver + /// counts instant/sorcery records (which fail-close on `Another`). Without the + /// `Or` arm this filter peels to `None` and the count stays fail-closed at 0. + #[test] + fn own_cast_exclusion_peels_or_of_marked_legs() { + let marked = + |ty| TargetFilter::Typed(TypedFilter::new(ty).properties(vec![FilterProp::Another])); + let filter = TargetFilter::Or { + filters: vec![marked(TypeFilter::Instant), marked(TypeFilter::Sorcery)], + }; + assert_eq!( + filter.peel_own_cast_exclusion(), + Some(Some(TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter::new(TypeFilter::Instant)), + TargetFilter::Typed(TypedFilter::new(TypeFilter::Sorcery)), + ], + })), + "each Or leg's Another marker is peeled, leaving the residual type Or" + ); + + // A mixed Or (only one leg marked) is NOT a unified own-cast filter and + // must not be peeled — leave it to fail-close, avoiding a false exclusion. + let mixed = TargetFilter::Or { + filters: vec![ + marked(TypeFilter::Instant), + TargetFilter::Typed(TypedFilter::new(TypeFilter::Sorcery)), + ], + }; + assert_eq!(mixed.peel_own_cast_exclusion(), None); + } + + /// CR 106.6: `ManaSpendRestriction::has_payable_branch` must classify each + /// leaf by whether its lowered runtime gate can return `true` at a reachable + /// production payment site today, and short-circuit `Any` in both directions. /// CR 609.4b + CR 106.1a + CR 106.1b: the two spend permissions are distinct wire /// discriminants even though both project the colored-payment relaxation. #[test] diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 4076196b72..1e469e6ca3 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -1031,6 +1031,22 @@ pub struct SpellCastRecord { /// cast-time for per-turn spell-history filters ("first kicked spell each turn"). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub was_kicked: bool, + /// CR 400.7: Storage id of the spell object this record was created for. The + /// engine keeps ObjectId STABLE across zone changes — `zones::move_to_zone` + /// never reallocates ids; instead `reset_for_battlefield_entry` bumps the + /// object's incarnation at the same storage id (CR 400.7 "new object"). A + /// same-id record is therefore NOT necessarily the same CR-object: a card + /// cast, dying, and recast this turn leaves TWO same-id records, and the + /// earlier one denotes a distinct prior object that DOES count as "another" + /// spell. Consumers must identify "this object's own cast" positionally: the + /// LAST same-id record in the caster's chronological turn history (a spell + /// pending on the stack cannot be cast again, so the most recent same-id + /// record is always the pending cast). incarnation is deliberately NOT + /// recorded — it bumps only on battlefield entry, so it cannot discriminate a + /// counter-and-recast, while positional identity is exact there too. `None` + /// for records built by Default / legacy deserialization. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spell_object_id: Option, } /// Snapshot of a land play's cast-capable origin for per-turn history queries. @@ -1063,6 +1079,7 @@ impl Default for SpellCastRecord { from_zone: Zone::Hand, cast_variant: CastingVariant::Normal, was_kicked: false, + spell_object_id: None, } } } @@ -20646,6 +20663,30 @@ mod tests { }"#; let record: SpellCastRecord = serde_json::from_str(no_field_json).unwrap(); assert_eq!(record.from_zone, Zone::Hand); + // CR 400.7: an absent `spell_object_id` field (legacy / pre-migration + // snapshot) deserializes to `None` — the record has no provenance. + assert_eq!(record.spell_object_id, None); + } + + /// CR 400.7: `spell_object_id` provenance survives a serde round trip when + /// present (`Some(id)`), and `None` is omitted from the serialized form + /// (`skip_serializing_if = "Option::is_none"`) so it never bloats snapshots. + #[test] + fn spell_cast_record_spell_object_id_round_trips() { + let original = SpellCastRecord { + spell_object_id: Some(ObjectId(42)), + ..Default::default() + }; + let json = serde_json::to_string(&original).unwrap(); + assert!(json.contains("spell_object_id")); + let round_tripped: SpellCastRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(round_tripped, original); + assert_eq!(round_tripped.spell_object_id, Some(ObjectId(42))); + + // `None` provenance is omitted from the serialized form. + let none_record = SpellCastRecord::default(); + let none_json = serde_json::to_string(&none_record).unwrap(); + assert!(!none_json.contains("spell_object_id")); } /// CR 601.2a: A snapshot with a real `from_zone` value (the modern non-Option @@ -20665,6 +20706,7 @@ mod tests { from_zone: Zone::Graveyard, cast_variant: CastingVariant::Normal, was_kicked: false, + spell_object_id: None, }; let json = serde_json::to_string(&original).unwrap(); let round_tripped: SpellCastRecord = serde_json::from_str(&json).unwrap(); diff --git a/crates/engine/tests/integration/narset_jeskai_waymaster_draw_spells_cast.rs b/crates/engine/tests/integration/narset_jeskai_waymaster_draw_spells_cast.rs index 32c59e159e..ad58eadf79 100644 --- a/crates/engine/tests/integration/narset_jeskai_waymaster_draw_spells_cast.rs +++ b/crates/engine/tests/integration/narset_jeskai_waymaster_draw_spells_cast.rs @@ -46,6 +46,7 @@ fn spell_record(name: &str) -> SpellCastRecord { from_zone: Zone::Hand, cast_variant: engine::types::game_state::CastingVariant::Normal, was_kicked: false, + spell_object_id: None, } }