diff --git a/crates/engine/src/ai_support/filter.rs b/crates/engine/src/ai_support/filter.rs index 9b930dd7fd..44d195a934 100644 --- a/crates/engine/src/ai_support/filter.rs +++ b/crates/engine/src/ai_support/filter.rs @@ -564,6 +564,7 @@ struct ObjectFingerprint { power: Option, toughness: Option, base_power: Option, + base_toughness: Option, name: String, foretold: bool, is_saddled: bool, @@ -637,6 +638,7 @@ impl Hash for ObjectFingerprint { self.power.hash(h); self.toughness.hash(h); self.base_power.hash(h); + self.base_toughness.hash(h); self.mana_cost.mana_value().hash(h); self.cost_x_paid.hash(h); self.damage_marked.hash(h); @@ -693,7 +695,8 @@ fn object_fingerprint(state: &GameState, id: ObjectId) -> Option bool { // one a population declares, rather than spot-checking fields — an omitted read // is fail-open for the CR 603.3b ordering gate, so field-by-field assertions // would be the wrong shape of check. Every field type already derives both. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct CurrentPtReads(u8); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PtReadScope { + Source, + Board, +} + +impl CurrentPtReads { + const SOURCE: Self = Self(1 << 0); + const BOARD: Self = Self(1 << 1); + + fn merge(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + fn add(&mut self, scope: PtReadScope) { + self.0 |= match scope { + PtReadScope::Source => Self::SOURCE.0, + PtReadScope::Board => Self::BOARD.0, + }; + } + + fn contains(self, scope: PtReadScope) -> bool { + let bit = match scope { + PtReadScope::Source => Self::SOURCE.0, + PtReadScope::Board => Self::BOARD.0, + }; + self.0 & bit != 0 + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct RwProfile { /// Source-scoped reads ONLY (live unless the structure freezes them, CR @@ -602,6 +635,12 @@ pub(crate) struct RwProfile { /// single-bit read fact (precedent: `legacy_batch_prompt`); `drop_writes` keeps /// it (a read). reads_member_bound: bool, + /// CR 613.4b-c: the scopes at which the profile reads live power/toughness + /// after layer 7c. A `BasePower` read shares the ObjectPt axis for ordinary + /// conflicts, but ObjectCounters feed only these live subsets. Keeping the + /// scopes separate is essential: a source P/T read must not make an + /// unrelated board BasePower read depend on a counter write. + current_pt_reads: CurrentPtReads, /// Writes scoped to the member's own Source/Recipient object. writes_self: KindSet, /// Board-/player-/stack-scoped writes (INCLUDES creation, event-object, and @@ -687,6 +726,7 @@ impl RwProfile { reads_event_live: false, legacy_batch_prompt: false, reads_member_bound: false, + current_pt_reads: CurrentPtReads::default(), writes_self: KindSet::EMPTY, writes_external: KindSet::EMPTY, writes_event_object: KindSet::EMPTY, @@ -730,6 +770,7 @@ impl RwProfile { // CR 603.10a: an unclassified subtree may consult a per-member binding ⇒ // fail-closed (refuses batch-T1). p.reads_member_bound = true; + p.current_pt_reads = CurrentPtReads::SOURCE.merge(CurrentPtReads::BOARD); p } @@ -741,6 +782,7 @@ impl RwProfile { self.reads_event_live |= o.reads_event_live; self.legacy_batch_prompt |= o.legacy_batch_prompt; self.reads_member_bound |= o.reads_member_bound; + self.current_pt_reads = self.current_pt_reads.merge(o.current_pt_reads); self.writes_self = self.writes_self.union(o.writes_self); self.writes_external = self.writes_external.union(o.writes_external); self.writes_event_object = self.writes_event_object.union(o.writes_event_object); @@ -909,6 +951,41 @@ struct SpanGate { write_mctrl: PlayerSpan, } +struct FeedContext<'a> { + read_census: &'a Census, + write_census: &'a Census, + read_zones: &'a ZoneSpan, + write_zones: &'a ZoneSpan, + pt: PtReadContext, + spans: SpanGate, +} + +#[derive(Clone, Copy)] +struct PtReadContext { + reads: CurrentPtReads, + scope: PtReadScope, +} + +impl<'a> FeedContext<'a> { + fn new( + read_census: &'a Census, + write_census: &'a Census, + read_zones: &'a ZoneSpan, + write_zones: &'a ZoneSpan, + pt: PtReadContext, + spans: SpanGate, + ) -> Self { + Self { + read_census, + write_census, + read_zones, + write_zones, + pt, + spans, + } + } +} + impl SpanGate { /// The ungated bundle (src-row call, §4.5): no player-kind reads route to /// `reads_src`, so the player/membership rows never fire here — `gate = false` @@ -931,21 +1008,15 @@ impl SpanGate { /// aggregate reads already carry a `SetMembership` tag. `Other` conflicts with /// everything. `PlayerLife → SetMembership` is deliberately ABSENT (the CR 800.4a /// player-loss cascade is the documented source-actor residual, §1.2). -fn feeds( - reads: KindSet, - writes: KindSet, - read_census: &Census, - write_census: &Census, - read_zones: &ZoneSpan, - write_zones: &ZoneSpan, - spans: SpanGate, -) -> bool { +fn feeds(reads: KindSet, writes: KindSet, context: FeedContext<'_>) -> bool { // PR-6.75 (CR 102.2/109.5): under `UniformAligned`, a player-keyed read and // write of the SAME kind conflict only when their relative-player spans can // name a common player. `!gate` ⇒ byte-identical (conjunct is `true`). - let player_ok = !spans.gate || player_span_overlap(spans.read_player, spans.write_player); + let player_ok = !context.spans.gate + || player_span_overlap(context.spans.read_player, context.spans.write_player); // PR-6.75 (CR 110.2): same, for the membership same-kind row's controller key. - let mctrl_ok = !spans.gate || player_span_overlap(spans.read_mctrl, spans.write_mctrl); + let mctrl_ok = !context.spans.gate + || player_span_overlap(context.spans.read_mctrl, context.spans.write_mctrl); if (writes.other && reads.any()) || (reads.other && writes.any()) { return true; } @@ -971,7 +1042,7 @@ fn feeds( return true; } // CR 122.1 + CR 613.4: counters change P/T. - if reads.object_pt && writes.object_counters { + if context.pt.reads.contains(context.pt.scope) && reads.object_pt && writes.object_counters { return true; } // CR 119.3: a life write feeds a life-change journal read. @@ -989,8 +1060,8 @@ fn feeds( // board count cannot feed a your-cards battlefield entry (Defense of the Heart). if reads.set_membership && writes.set_membership - && census_overlap(read_census, write_census) - && zone_overlap(read_zones, write_zones) + && census_overlap(context.read_census, context.write_census) + && zone_overlap(context.read_zones, context.write_zones) && mctrl_ok { return true; @@ -1160,13 +1231,19 @@ pub(crate) fn profiles_conflict(p: &RwProfile, s: &GroupStructure) -> bool { if feeds( live_src_reads, src_writes, - &p.reads_membership_census, - &src_write_census, - &p.reads_membership_zones, - &src_write_zones, - // §4.5: no player-kind read routes to `reads_src`, so the gated rows never - // fire here — pass the inert ungated bundle (fail-closed, documented). - SpanGate::ungated(), + FeedContext::new( + &p.reads_membership_census, + &src_write_census, + &p.reads_membership_zones, + &src_write_zones, + PtReadContext { + reads: p.current_pt_reads, + scope: PtReadScope::Source, + }, + // §4.5: no player-kind read routes to `reads_src`, so the gated rows never + // fire here — pass the inert ungated bundle (fail-closed, documented). + SpanGate::ungated(), + ), ) { return true; } @@ -1222,11 +1299,17 @@ pub(crate) fn profiles_conflict(p: &RwProfile, s: &GroupStructure) -> bool { if feeds( board_reads, board_writes, - &p.reads_membership_census, - &board_write_census, - &p.reads_membership_zones, - &board_write_zones, - board_spans, + FeedContext::new( + &p.reads_membership_census, + &board_write_census, + &p.reads_membership_zones, + &board_write_zones, + PtReadContext { + reads: p.current_pt_reads, + scope: PtReadScope::Board, + }, + board_spans, + ), ) { return true; } @@ -2069,6 +2152,7 @@ fn legacy_quantity_ref(x: &QuantityRef) -> bool { QuantityRef::CountersOn { scope, .. } | QuantityRef::Intensity { scope, .. } | QuantityRef::Power { scope, .. } + | QuantityRef::BasePower { scope, .. } | QuantityRef::Toughness { scope, .. } | QuantityRef::ObjectManaValue { scope, .. } | QuantityRef::ObjectColorCount { scope, .. } @@ -3569,6 +3653,26 @@ fn reads_src_of(k: StateKind) -> RwProfile { p.reads_src = KindSet::one(k); p } + +fn current_pt_scope(scope: &ObjectScope) -> CurrentPtReads { + match scope { + ObjectScope::Source => CurrentPtReads::SOURCE, + ObjectScope::Target | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { + CurrentPtReads::BOARD + } + // CR 120.1 + CR 208.3: a batch-source P/T read is a live board + // characteristic read, so counter writes to the batch population feed it. + ObjectScope::BatchSource => CurrentPtReads::BOARD, + ObjectScope::Recipient + | ObjectScope::EventSource + | ObjectScope::CostPaidObject + | ObjectScope::AmassedArmy + | ObjectScope::EventTarget + | ObjectScope::OtherRevealedCard + | ObjectScope::OwnedLinkedExileCard => CurrentPtReads::default(), + } +} + /// CR 603.10a: a source-referential look-back / cast-time fact — frozen, never /// sibling-fed, but marks source-dependence (`source_independent` false). fn frozen_source_read() -> RwProfile { @@ -3772,6 +3876,13 @@ fn characteristic_source_read_bounded(source: &CardTypeSetSource) -> RwProfile { /// value kind AND `SetMembership` (a membership write changes the aggregate, §2). fn board_value_aggregate_read(filter: &TargetFilter, value: StateKind) -> RwProfile { let mut p = board_membership_read(filter); + if matches!(value, StateKind::ObjectPt) { + if filter_is_self_scoped(filter) { + p.current_pt_reads.add(PtReadScope::Source); + } else { + p.current_pt_reads.add(PtReadScope::Board); + } + } if filter_is_self_scoped(filter) { p.reads_src.set(value); } else { @@ -3831,7 +3942,15 @@ fn share_quality_operand_read(f: &TargetFilter) -> RwProfile { | TargetFilter::SelfRef | TargetFilter::SourceOrPaired => RwProfile::empty(), // Fail-closed: any other reference is a live board characteristic read. - _ => reads_board_of(StateKind::ObjectPt), + _ => { + let mut p = reads_board_of(StateKind::ObjectPt); + // CR 613.4c: this live board characteristic carrier observes the + // post-counter value, so an ObjectCounters write remains a real + // dependency. Keep the scope on the board row; frozen and + // per-resolution operands above must stay independent. + p.current_pt_reads.add(PtReadScope::Board); + p + } } } @@ -6053,8 +6172,12 @@ fn rw_quantity_ref(x: &QuantityRef) -> RwProfile { QuantityRef::CountersOn { scope, .. } | QuantityRef::Intensity { scope, .. } => { read_object_scope(scope, StateKind::ObjectCounters) } - QuantityRef::Power { scope, .. } - | QuantityRef::Toughness { scope, .. } + QuantityRef::Power { scope, .. } | QuantityRef::Toughness { scope, .. } => { + let mut p = read_object_scope(scope, StateKind::ObjectPt); + p.current_pt_reads = current_pt_scope(scope); + p + } + QuantityRef::BasePower { scope, .. } | QuantityRef::ObjectManaValue { scope, .. } | QuantityRef::ObjectColorCount { scope, .. } | QuantityRef::ObjectNameWordCount { scope, .. } @@ -6290,7 +6413,9 @@ fn rw_ability_condition(x: &AbilityCondition) -> RwProfile { let mut p = if *use_lki { frozen_source_read() } else { - reads_board_of(StateKind::ObjectPt) + let mut p = reads_board_of(StateKind::ObjectPt); + p.current_pt_reads.add(PtReadScope::Board); + p }; // CR 608.2c (PR-6.75 c5): Some(n) tests a specific DECLARED chain slot // resolved per member instance — the same per-member binding @@ -6310,7 +6435,11 @@ fn rw_ability_condition(x: &AbilityCondition) -> RwProfile { // begin-combat return is gated on controlling a creature with power >= 4 (a // P/T-constrained control census) — proving disjointness needs the runtime // source P/T the profile cannot see, so its control read stays fail-closed. - AbilityCondition::SourceMatchesFilter { filter: _ } => reads_src_of(StateKind::ObjectPt), + AbilityCondition::SourceMatchesFilter { filter: _ } => { + let mut p = reads_src_of(StateKind::ObjectPt); + p.current_pt_reads.add(PtReadScope::Source); + p + } AbilityCondition::SourceIsTapped => reads_src_of(StateKind::TapState), AbilityCondition::ControllerControlsMatching { filter } => board_membership_read(filter), AbilityCondition::ScopedPlayerMatches { filter } => rw_player_filter(filter), @@ -6448,7 +6577,11 @@ fn rw_trigger_condition(x: &TriggerCondition) -> RwProfile { reads_board_of(StateKind::ObjectCounters) } TriggerCondition::SourceIsTapped => reads_src_of(StateKind::TapState), - TriggerCondition::SourceMatchesFilter { filter: _ } => reads_src_of(StateKind::ObjectPt), + TriggerCondition::SourceMatchesFilter { filter: _ } => { + let mut p = reads_src_of(StateKind::ObjectPt); + p.current_pt_reads.add(PtReadScope::Source); + p + } TriggerCondition::NoSpellsCastLastTurn | TriggerCondition::TwoOrMoreSpellsCastLastTurn | TriggerCondition::CastSpellThisTurn { .. } @@ -6563,7 +6696,11 @@ fn rw_static_condition(x: &StaticCondition) -> RwProfile { StaticCondition::AnyPlayerAttackedYouLastTurn => { reads_player_of(StateKind::TurnStructure) } - StaticCondition::SourceMatchesFilter { filter: _ } => reads_src_of(StateKind::ObjectPt), + StaticCondition::SourceMatchesFilter { filter: _ } => { + let mut p = reads_src_of(StateKind::ObjectPt); + p.current_pt_reads.add(PtReadScope::Source); + p + } // CR 401/402: reads the controller's library top card (contents + order). // A draw/scry/surveil/mill/shuffle writes `HandLibrary`, so marking this // gate as reading `HandLibrary` invalidates it whenever the library top @@ -7121,6 +7258,16 @@ mod tests { scope: ObjectScope::Source, } } + fn base_power_target() -> QuantityRef { + QuantityRef::BasePower { + scope: ObjectScope::Target, + } + } + fn base_power_src() -> QuantityRef { + QuantityRef::BasePower { + scope: ObjectScope::Source, + } + } fn tough_recip() -> QuantityRef { QuantityRef::Toughness { scope: ObjectScope::Recipient, @@ -7597,6 +7744,18 @@ mod tests { assert!(conflicts(&a, &se())); } + #[test] + fn base_power_read_does_not_feed_from_counter_write() { + // CR 208.4b + CR 613.4b: counters modify current P/T in layer 7c, + // but a base-power read observes the layer-7b carrier and must not + // create a false dependency on the counter writer. + let a = cond( + ra(put_counter_all(qfix(1), creature())), + qcheck(base_power_src(), 4), + ); + assert!(!conflicts(&a, &se())); + } + #[test] fn base_graveyard_return_board_membership_conflict() { // board creature-count read × return-to-battlefield membership write, @@ -8529,6 +8688,25 @@ mod tests { ); } + /// CR 613.4b-c: a BasePower read observes the layer-7b base value, not the + /// layer-7c counter-modified value. It therefore shares the ObjectPt axis + /// with base-setting writes but does not receive the ObjectCounters → live + /// P/T feed that a Power/Toughness read receives. + #[test] + fn base_power_read_does_not_depend_on_counter_write() { + let reader = cond( + ra(put_counter_all(qfix(1), creature())), + qcheck(base_power_target(), 1), + ); + let profile = ability_rw_profile(&reader); + assert!(!profile.current_pt_reads.contains(PtReadScope::Board)); + assert!( + !conflicts(&reader, &batch()), + "BasePower target read must not conflict with an unrelated counter write" + ); + assert!(profile.writes_external.object_counters); + } + /// The new `StateKind::TurnStructure` kind: `KindSet` add/union/subtract behave, /// and the `feeds` matrix isolates it — a sequencing read × sequencing write /// conflicts, but it neither feeds nor is fed by `ObjectPt`. @@ -8547,16 +8725,58 @@ mod tests { let (nc, nz) = (Census::None, ZoneSpan::None); let sg = SpanGate::ungated(); assert!( - feeds(ts, ts, &nc, &nc, &nz, &nz, sg), + feeds( + ts, + ts, + FeedContext::new( + &nc, + &nc, + &nz, + &nz, + PtReadContext { + reads: CurrentPtReads::default(), + scope: PtReadScope::Board, + }, + sg, + ), + ), "TurnStructure read × write ⇒ self-conflict" ); let pt = KindSet::one(StateKind::ObjectPt); assert!( - !feeds(pt, ts, &nc, &nc, &nz, &nz, sg), + !feeds( + pt, + ts, + FeedContext::new( + &nc, + &nc, + &nz, + &nz, + PtReadContext { + reads: CurrentPtReads::default(), + scope: PtReadScope::Board, + }, + sg, + ), + ), "TurnStructure write does not feed ObjectPt" ); assert!( - !feeds(ts, pt, &nc, &nc, &nz, &nz, sg), + !feeds( + ts, + pt, + FeedContext::new( + &nc, + &nc, + &nz, + &nz, + PtReadContext { + reads: CurrentPtReads::default(), + scope: PtReadScope::Board, + }, + sg, + ), + ), "ObjectPt write does not feed TurnStructure" ); } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index a542a50d52..d9de54d877 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -1994,7 +1994,7 @@ fn scan_quantity_ref(x: &QuantityRef, mode: ScanMode) -> Axes { // always safe — it only forces an extra re-scan, never a stale read). QuantityRef::TargetControllerCounter { kind: _ } => Axes::CONSERVATIVE, QuantityRef::Variable { name: _ } => Axes::NONE, - QuantityRef::Power { scope, .. } => { + QuantityRef::Power { scope, .. } | QuantityRef::BasePower { scope, .. } => { let mut acc = Axes { event: false, sibling: true, @@ -7694,6 +7694,9 @@ mod tests { QuantityRef::Power { scope: ObjectScope::Source, }, + QuantityRef::BasePower { + scope: ObjectScope::Source, + }, QuantityRef::CountersOn { scope: ObjectScope::Source, counter_type: None, @@ -7744,6 +7747,11 @@ mod tests { scope: ObjectScope::EventSource, } ))); + assert!(ability_uses_event_context(&ability_with_amount( + QuantityRef::BasePower { + scope: ObjectScope::EventSource, + } + ))); // (2) TargetFilter::TriggeringSourceController via QuantityRef::ObjectCount filter. assert!(ability_uses_event_context(&ability_with_amount( QuantityRef::ObjectCount { @@ -8042,6 +8050,11 @@ mod tests { scope: ObjectScope::Source } ))); + assert!(ability_reads_sibling_mutable(&ability_with_amount( + QuantityRef::BasePower { + scope: ObjectScope::Source + } + ))); // Fixed drain reads no sibling-mutable state — safe to auto-resolve. assert!(!ability_reads_sibling_mutable(&fixed_drain())); } diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index 3d3fd04ef4..8641acef8a 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -4416,10 +4416,15 @@ fn quantity_ref_target_slot_spec(qty: &QuantityRef) -> Option { QuantityRef::Power { scope: ObjectScope::Target, } + | QuantityRef::BasePower { + scope: ObjectScope::Target, + } | QuantityRef::Toughness { scope: ObjectScope::Target, } => Some(TargetFilter::Typed(TypedFilter::creature())), - QuantityRef::Power { .. } | QuantityRef::Toughness { .. } => None, + QuantityRef::Power { .. } + | QuantityRef::BasePower { .. } + | QuantityRef::Toughness { .. } => None, // CR 202.3 + CR 115.1: the ref carries its own slot filter. QuantityRef::TargetObjectManaValue { filter } => Some((**filter).clone()), // CR 701.9 + CR 115.1: cards a single targeted opponent discarded this diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 398854b59b..3d53dde6b0 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -18731,6 +18731,7 @@ fn quantity_ref_is_board_state_relative(qty: &QuantityRef) -> bool { | QuantityRef::Aggregate { filter, .. } => !filter_references_target_player(filter), QuantityRef::CountersOn { scope, .. } | QuantityRef::Power { scope } + | QuantityRef::BasePower { scope } | QuantityRef::Toughness { scope } | QuantityRef::ObjectManaValue { scope } | QuantityRef::ObjectColorCount { scope } diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 8a5e538b7a..0c96fb31af 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -1396,6 +1396,20 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { ObjectScope::AmassedArmy => "amassed Army's power".into(), ObjectScope::BatchSource => "batch source's power".into(), }, + QuantityRef::BasePower { scope } => match scope { + ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { + "self base power".into() + } + ObjectScope::Target => "target's base power".into(), + ObjectScope::Recipient => "recipient's base power".into(), + ObjectScope::EventSource => "event source's base power".into(), + ObjectScope::EventTarget => "event target's base power".into(), + ObjectScope::CostPaidObject => "referenced object's base power".into(), + ObjectScope::OtherRevealedCard => "other revealed card's base power".into(), + ObjectScope::OwnedLinkedExileCard => "owned linked-exiled card's base power".into(), + ObjectScope::AmassedArmy => "amassed Army's base power".into(), + ObjectScope::BatchSource => "batch source's base power".into(), + }, QuantityRef::Toughness { scope } => match scope { ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { "self toughness".into() @@ -8178,11 +8192,25 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) { ObjectScope::EventSource => ("EventSourcePower", Handled), ObjectScope::EventTarget => ("EventTargetPower", Handled), ObjectScope::CostPaidObject => ("CostPaidObjectPower", Handled), - ObjectScope::OtherRevealedCard => ("OtherRevealedCardPower", Handled), - ObjectScope::OwnedLinkedExileCard => ("OwnedLinkedExileCardPower", Handled), + ObjectScope::OtherRevealedCard => ("OtherRevealedCardPower", Unhandled), + ObjectScope::OwnedLinkedExileCard => ("OwnedLinkedExileCardPower", Unhandled), ObjectScope::AmassedArmy => ("AmassedArmyPower", Handled), ObjectScope::BatchSource => ("BatchSourcePower", Handled), }, + QuantityRef::BasePower { scope } => match scope { + ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { + ("SelfBasePower", Handled) + } + ObjectScope::Target => ("TargetBasePower", Handled), + ObjectScope::Recipient => ("RecipientBasePower", Handled), + ObjectScope::EventSource => ("EventSourceBasePower", Handled), + ObjectScope::EventTarget => ("EventTargetBasePower", Handled), + ObjectScope::CostPaidObject => ("CostPaidObjectBasePower", Handled), + ObjectScope::OtherRevealedCard => ("OtherRevealedCardBasePower", Unhandled), + ObjectScope::OwnedLinkedExileCard => ("OwnedLinkedExileCardBasePower", Unhandled), + ObjectScope::AmassedArmy => ("AmassedArmyBasePower", Handled), + ObjectScope::BatchSource => ("BatchSourceBasePower", Handled), + }, QuantityRef::Toughness { scope } => match scope { ObjectScope::Source | ObjectScope::Anaphoric | ObjectScope::Demonstrative => { ("SelfToughness", Handled) @@ -8192,8 +8220,8 @@ fn quantity_ref_feature(qref: &QuantityRef) -> (&'static str, FeatureSupport) { ObjectScope::EventSource => ("EventSourceToughness", Handled), ObjectScope::EventTarget => ("EventTargetToughness", Handled), ObjectScope::CostPaidObject => ("CostPaidObjectToughness", Handled), - ObjectScope::OtherRevealedCard => ("OtherRevealedCardToughness", Handled), - ObjectScope::OwnedLinkedExileCard => ("OwnedLinkedExileCardToughness", Handled), + ObjectScope::OtherRevealedCard => ("OtherRevealedCardToughness", Unhandled), + ObjectScope::OwnedLinkedExileCard => ("OwnedLinkedExileCardToughness", Unhandled), ObjectScope::AmassedArmy => ("AmassedArmyToughness", Handled), ObjectScope::BatchSource => ("BatchSourceToughness", Handled), }, diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index 716c635885..03030bc0d9 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -1479,20 +1479,16 @@ pub fn resolve_add( ability: &ResolvedAbility, events: &mut Vec, ) -> Result<(), EffectError> { - let (counter_type, counter_num) = match &ability.effect { + let (counter_type, count) = match &ability.effect { Effect::PutCounter { counter_type, count, .. - } => { - // CR 107.1b: Ability-context resolve so X-counter effects (e.g. "put X +1/+1 counters") - // pick up the caster-chosen X. - let resolved_count = - crate::game::quantity::resolve_quantity_with_targets(state, count, ability).max(0) - as u32; - (counter_type.clone(), resolved_count) - } - _ => (CounterType::Plus1Plus1, 1), + } => (counter_type.clone(), count.clone()), + _ => ( + CounterType::Plus1Plus1, + crate::types::ability::QuantityExpr::Fixed { value: 1 }, + ), }; // CR 601.2d: If distribution was assigned at cast time, apply per-target counter counts. @@ -1514,9 +1510,27 @@ pub fn resolve_add( .collect() } else { let targets = resolve_defined_or_targets(state, ability); + // CR 608.2c: A quantity bound to the resolved recipient (such as + // Sovereign Okinec Ahau's "the difference") is evaluated separately + // for each object. Source-relative quantities remain one shared value. + let count_uses_recipient = crate::game::quantity::quantity_expr_uses_recipient(&count); + let counter_num_shared = (!count_uses_recipient).then(|| { + // CR 107.1b: Ability-context resolve preserves the announced X for + // source-relative counter quantities. + crate::game::quantity::resolve_quantity_with_targets(state, &count, ability).max(0) + as u32 + }); targets .into_iter() .map(|obj_id| { + let counter_num = if count_uses_recipient { + crate::game::quantity::resolve_quantity_with_targets_and_recipient( + state, &count, ability, obj_id, + ) + .max(0) as u32 + } else { + counter_num_shared.expect("shared counter quantity must be resolved") + }; object_counter_addition( ability.controller, obj_id, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 67614a7d94..da036f68d6 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3398,6 +3398,7 @@ fn quantity_ref_counts_population_matching( | QuantityRef::TargetControllerCounter { .. } | QuantityRef::Variable { .. } | QuantityRef::Power { .. } + | QuantityRef::BasePower { .. } | QuantityRef::Intensity { .. } | QuantityRef::Toughness { .. } | QuantityRef::ObjectManaValue { .. } @@ -4266,6 +4267,7 @@ fn quantity_ref_references_demonstrative(qty: &QuantityRef) -> bool { use crate::types::ability::ObjectScope; let scope = match qty { QuantityRef::ObjectManaValue { scope } + | QuantityRef::BasePower { scope } | QuantityRef::Power { scope } | QuantityRef::Toughness { scope } | QuantityRef::CountersOn { scope, .. } diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 8380be4bf5..222651c5da 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -1296,6 +1296,8 @@ pub(crate) fn materialize_token_spec_body( object.base_name = ch.display_name.clone(); object.base_power = ch.power; object.base_toughness = ch.toughness; + object.layer_base_power = ch.power; + object.layer_base_toughness = ch.toughness; object.card_types = CardType { supertypes: ch.supertypes.clone(), core_types: ch.core_types.clone(), diff --git a/crates/engine/src/game/effects/token_copy.rs b/crates/engine/src/game/effects/token_copy.rs index 10a5464a9f..f3bdf56419 100644 --- a/crates/engine/src/game/effects/token_copy.rs +++ b/crates/engine/src/game/effects/token_copy.rs @@ -1518,12 +1518,14 @@ fn apply_token_modifications( if let Some(token) = state.objects.get_mut(&token_id) { token.base_power = Some(*value); token.power = Some(*value); + token.layer_base_power = Some(*value); } } ContinuousModification::SetToughness { value } => { if let Some(token) = state.objects.get_mut(&token_id) { token.base_toughness = Some(*value); token.toughness = Some(*value); + token.layer_base_toughness = Some(*value); } } // CR 707.9b: fixed additive P/T exceptions are baked into the @@ -1532,12 +1534,14 @@ fn apply_token_modifications( if let Some(token) = state.objects.get_mut(&token_id) { token.base_power = token.base_power.map(|p| p + *value); token.power = token.power.map(|p| p + *value); + token.layer_base_power = token.layer_base_power.map(|p| p + *value); } } ContinuousModification::AddToughness { value } => { if let Some(token) = state.objects.get_mut(&token_id) { token.base_toughness = token.base_toughness.map(|t| t + *value); token.toughness = token.toughness.map(|t| t + *value); + token.layer_base_toughness = token.layer_base_toughness.map(|t| t + *value); } } // CR 707.9b: "except its base power and toughness are each equal @@ -1553,6 +1557,7 @@ fn apply_token_modifications( if let Some(token) = state.objects.get_mut(&token_id) { token.base_power = Some(val); token.power = Some(val); + token.layer_base_power = Some(val); } } ContinuousModification::SetToughnessDynamic { value } => { @@ -1565,6 +1570,7 @@ fn apply_token_modifications( if let Some(token) = state.objects.get_mut(&token_id) { token.base_toughness = Some(val); token.toughness = Some(val); + token.layer_base_toughness = Some(val); } } // CR 707.9b + CR 306.5b/c: Starting-loyalty exceptions are already @@ -1748,18 +1754,22 @@ pub(crate) fn apply_immediate_copy_token_modifications_to_object( ContinuousModification::SetPower { value } => { token.base_power = Some(*value); token.power = Some(*value); + token.layer_base_power = Some(*value); } ContinuousModification::SetToughness { value } => { token.base_toughness = Some(*value); token.toughness = Some(*value); + token.layer_base_toughness = Some(*value); } ContinuousModification::AddPower { value } => { token.base_power = token.base_power.map(|p| p + *value); token.power = token.power.map(|p| p + *value); + token.layer_base_power = token.layer_base_power.map(|p| p + *value); } ContinuousModification::AddToughness { value } => { token.base_toughness = token.base_toughness.map(|t| t + *value); token.toughness = token.toughness.map(|t| t + *value); + token.layer_base_toughness = token.layer_base_toughness.map(|t| t + *value); } ContinuousModification::SetStartingLoyalty { value } => { token.base_loyalty = Some(*value); diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index fab9faad3f..aefc9d6000 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -19352,9 +19352,9 @@ mod stage2_injector_tests { // resume/finalization helpers are above this existing producer; // they do not mint an optional-effect prompt. The census above // still finds exactly the same five production producers. - "game/effects/mod.rs:7344".to_string(), - "game/effects/mod.rs:7421".to_string(), - "game/effects/mod.rs:11248".to_string(), + "game/effects/mod.rs:7346".to_string(), + "game/effects/mod.rs:7423".to_string(), + "game/effects/mod.rs:11250".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/filter.rs b/crates/engine/src/game/filter.rs index 7d9c4eb0ec..562da98332 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -5302,7 +5302,13 @@ fn pt_value_from_pair(stat: PtStat, power: Option, toughness: Option) fn object_pt_value(obj: &GameObject, stat: PtStat, scope: PtValueScope) -> i32 { match scope { PtValueScope::Current => pt_value_from_pair(stat, obj.power, obj.toughness), - PtValueScope::Base => pt_value_from_pair(stat, obj.base_power, obj.base_toughness), + // CR 208.4b + CR 613.4a-b: base P/T includes characteristic-defining + // and setting effects, but excludes layer-7c modifications and counters. + PtValueScope::Base => pt_value_from_pair( + stat, + obj.layer_base_power.or(obj.base_power), + obj.layer_base_toughness.or(obj.base_toughness), + ), } } @@ -6054,7 +6060,9 @@ fn matches_filter_prop( // CR 208.1 + CR 613.4b: Match creatures whose current (post-layer) power // exceeds their base power (layer-7b baseline incl. CDA, before // counters/pumps in 7c–7e). - FilterProp::PowerExceedsBase => obj.power.unwrap_or(0) > obj.base_power.unwrap_or(0), + FilterProp::PowerExceedsBase => { + obj.power.unwrap_or(0) > obj.layer_base_power.or(obj.base_power).unwrap_or(0) + } // Match objects whose name differs from all controlled battlefield objects matching the filter. FilterProp::DifferentNameFrom { filter } => { let controller = source.controller.unwrap_or(PlayerId(0)); @@ -12944,6 +12952,33 @@ mod tests { )); } + /// CR 208.4b + CR 613.4a-c: a live base-toughness read observes the + /// layer-7a/7b carrier, not printed toughness and not a later modifier. + #[test] + fn live_base_scope_uses_layer_base_toughness() { + let mut object = crate::game::game_object::GameObject::new( + ObjectId(7), + CardId(7), + PlayerId(0), + "Layered Creature".to_string(), + Zone::Battlefield, + ); + object.base_toughness = Some(1); + object.layer_base_toughness = Some(4); + object.toughness = Some(7); + + assert_eq!( + object_pt_value(&object, PtStat::Toughness, PtValueScope::Base), + 4, + "base toughness must be the layer-7b value" + ); + assert_eq!( + object_pt_value(&object, PtStat::Toughness, PtValueScope::Current), + 7, + "current toughness must retain the later layer-7c value" + ); + } + /// CR 208.4b + CR 613.4b + CR 603.10a: End-to-end look-back path. Drives a /// REAL `GameObject` (base 1/1) with a +1/+1 counter through the live layer /// pipeline (`evaluate_layers` makes current power/toughness 2/2 while the diff --git a/crates/engine/src/game/flip.rs b/crates/engine/src/game/flip.rs index e5db14ecc4..b8e927a4f1 100644 --- a/crates/engine/src/game/flip.rs +++ b/crates/engine/src/game/flip.rs @@ -325,8 +325,10 @@ pub(crate) fn apply_flipped_face_to_object(obj: &mut GameObject, face: BackFaceD // CR 710.1b: alternative power and toughness. obj.power = face.power; obj.base_power = face.power; + obj.layer_base_power = face.power; obj.toughness = face.toughness; obj.base_toughness = face.toughness; + obj.layer_base_toughness = face.toughness; // CR 306.5b + CR 310.4b: loyalty/defense track the alternative type line. obj.loyalty = face.loyalty; diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index f3feac174d..61bb993c6c 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -516,6 +516,17 @@ pub struct GameObject { pub name: String, pub power: Option, pub toughness: Option, + /// CR 208.4b + CR 613.4a-b: Current base power after layer 7a/7b set effects. + /// `base_power` remains the printed/copiable baseline; this derived carrier + /// is reset from it at the start of each layer pass and is updated by + /// layer-7a/7b setters, before layer-7c modifications are applied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_base_power: Option, + /// CR 208.4b + CR 613.4a-b: Current base toughness after layer 7a/7b set + /// effects. `base_toughness` remains the printed/copiable baseline; this + /// carrier stays paired with `layer_base_power` through layer evaluation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_base_toughness: Option, pub loyalty: Option, /// CR 306.5b: Printed loyalty is the entry-counter baseline; battlefield /// loyalty itself is counter-derived (CR 306.5c). @@ -1318,6 +1329,8 @@ fn _gameobject_partition_is_total(o: &GameObject) { name: _, power: _, toughness: _, + layer_base_power: _, + layer_base_toughness: _, loyalty: _, printed_loyalty: _, defense: _, @@ -1687,6 +1700,8 @@ impl GameObject { // change permanent and zone-independent. self.base_power = Some(*power); self.base_toughness = Some(*toughness); + self.layer_base_power = Some(*power); + self.layer_base_toughness = Some(*toughness); } PerpetualModification::ModifyPowerToughness { power_delta, @@ -1704,6 +1719,8 @@ impl GameObject { .saturating_add(*toughness_delta); self.base_power = Some(base_power); self.base_toughness = Some(base_toughness); + self.layer_base_power = Some(base_power); + self.layer_base_toughness = Some(base_toughness); } PerpetualModification::GrantKeywords { keywords } => { for keyword in keywords { @@ -1752,6 +1769,8 @@ impl GameObject { } self.base_power = Some(*power); self.base_toughness = Some(*toughness); + self.layer_base_power = Some(*power); + self.layer_base_toughness = Some(*toughness); for keyword in keywords { if !self.base_keywords.contains(keyword) { self.base_keywords.push(keyword.clone()); @@ -2068,8 +2087,8 @@ impl GameObject { // so `PtComparison { scope: Base }` look-back filters read the // event-time base (a base-1/1 with a +1/+1 counter records base 1, // current 2). - base_power: self.base_power, - base_toughness: self.base_toughness, + base_power: self.layer_base_power.or(self.base_power), + base_toughness: self.layer_base_toughness.or(self.base_toughness), // CR 709.4b: Off the stack, a split card's colors are the combined // colors of both halves (`effective_colors` no-ops for single-face). colors: self.effective_colors(), @@ -2112,9 +2131,15 @@ impl GameObject { if self.base_power.is_none() && self.power.is_some() { self.base_power = self.power; } + if self.layer_base_power.is_none() { + self.layer_base_power = self.base_power; + } if self.base_toughness.is_none() && self.toughness.is_some() { self.base_toughness = self.toughness; } + if self.layer_base_toughness.is_none() { + self.layer_base_toughness = self.base_toughness; + } if self.base_loyalty.is_none() && self.loyalty.is_some() { self.base_loyalty = self.loyalty; } @@ -2207,6 +2232,8 @@ impl GameObject { name: name.clone(), power: None, toughness: None, + layer_base_power: None, + layer_base_toughness: None, loyalty: None, printed_loyalty: None, defense: None, @@ -2343,8 +2370,8 @@ impl GameObject { toughness: self.toughness, // CR 208.4b + CR 613.4b: Layer-7b base values, mirroring how // `power`/`toughness` capture the post-layer-7 current values. - base_power: self.base_power, - base_toughness: self.base_toughness, + base_power: self.layer_base_power.or(self.base_power), + base_toughness: self.layer_base_toughness.or(self.base_toughness), // CR 202.3d + CR 709.4b: combined mana value / colors for a split card // off the stack (no-op for single-face, on-stack, and battlefield // Rooms, which gate out) so look-back queries read the CR-correct @@ -2554,6 +2581,8 @@ impl GameObject { self.name = self.base_name.clone(); self.power = self.base_power; self.toughness = self.base_toughness; + self.layer_base_power = self.base_power; + self.layer_base_toughness = self.base_toughness; self.loyalty = self.base_loyalty; self.printed_loyalty = self.base_printed_loyalty; // CR 310.4a + CR 400.7: Battle defense reverts to printed baseline off the battlefield. diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index e1a1ddfdd4..fb7035ddd3 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -2098,6 +2098,10 @@ fn seed_live_characteristics_from_base(obj: &mut crate::game::game_object::GameO obj.name = obj.base_name.clone(); obj.power = obj.base_power; obj.toughness = obj.base_toughness; + // CR 208.4b + CR 613.4b: layer 7b starts from the printed/copiable base; + // later 7b setters update this carrier while 7c leaves it unchanged. + obj.layer_base_power = obj.base_power; + obj.layer_base_toughness = obj.base_toughness; obj.loyalty = obj.base_loyalty; obj.card_types = obj.base_card_types.clone(); obj.mana_cost = obj.base_mana_cost.clone(); @@ -2890,6 +2894,7 @@ fn quantity_ref_reads_zone(qty: &QuantityRef, zone: Zone) -> bool { | QuantityRef::TargetControllerCounter { .. } | QuantityRef::Variable { .. } | QuantityRef::Power { .. } + | QuantityRef::BasePower { .. } | QuantityRef::Intensity { .. } | QuantityRef::Toughness { .. } | QuantityRef::ObjectManaValue { .. } @@ -3218,6 +3223,7 @@ fn quantity_ref_reads_life(qty: &QuantityRef) -> bool { | QuantityRef::TargetControllerCounter { .. } | QuantityRef::Variable { .. } | QuantityRef::Power { .. } + | QuantityRef::BasePower { .. } | QuantityRef::Intensity { .. } | QuantityRef::Toughness { .. } | QuantityRef::ObjectManaValue { .. } @@ -7766,9 +7772,14 @@ fn apply_continuous_effect_filtered( } ContinuousModification::SetPower { value } => { obj.power = Some(*value); + // CR 613.4b: a fixed set effect changes current base power, + // not only the post-layer live power field. + obj.layer_base_power = Some(*value); } ContinuousModification::SetToughness { value } => { obj.toughness = Some(*value); + // CR 613.4b: a fixed set effect changes current base toughness. + obj.layer_base_toughness = Some(*value); } // CR 702.16g: "Protection from [A] and from [B]" behaves as two // separate protection abilities. Parameterized keywords like @@ -8122,23 +8133,35 @@ fn apply_continuous_effect_filtered( ContinuousModification::SetDynamicPower { .. } => { if let Some(val) = dynamic_pt { obj.power = Some(val); + // CR 613.4a: a characteristic-defining power sets the + // current base power before layer-7b set effects run. + obj.layer_base_power = Some(val); } } ContinuousModification::SetDynamicToughness { .. } => { if let Some(val) = dynamic_pt { obj.toughness = Some(val); + // CR 613.4a: a dynamic characteristic-defining toughness sets + // the current base toughness before layer-7b effects. + obj.layer_base_toughness = Some(val); } } // CR 613.4b: Layer 7b — set base power to dynamic value (e.g., Biomass Mutation). ContinuousModification::SetPowerDynamic { .. } => { if let Some(val) = dynamic_pt { obj.power = Some(val); + // CR 613.4b: dynamic layer-7b setters share the same + // authoritative current-base carrier as fixed setters. + obj.layer_base_power = Some(val); } } // CR 613.4b: Layer 7b — set base toughness to dynamic value. ContinuousModification::SetToughnessDynamic { .. } => { if let Some(val) = dynamic_pt { obj.toughness = Some(val); + // CR 613.4b: dynamic layer-7b setters update the authoritative + // current-base carrier just like fixed setters. + obj.layer_base_toughness = Some(val); } } // CR 613.4c: Additive dynamic P/T modification (layer 7c). diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index f605150910..4a0dd9b967 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -232,6 +232,8 @@ pub fn apply_face_down_creature_characteristics( obj.toughness = toughness; obj.base_power = power; obj.base_toughness = toughness; + obj.layer_base_power = power; + obj.layer_base_toughness = toughness; obj.card_types = CardType { supertypes: vec![], core_types, diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index de0021ad5f..9192ea3c00 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -153,6 +153,8 @@ pub fn apply_card_face_to_object(obj: &mut GameObject, card_face: &CardFace) { obj.color = color.clone(); obj.base_power = power; obj.base_toughness = toughness; + obj.layer_base_power = power; + obj.layer_base_toughness = toughness; obj.base_name = card_face.name.clone(); obj.base_loyalty = loyalty; obj.base_printed_loyalty = printed_loyalty; @@ -317,6 +319,8 @@ pub fn apply_back_face_to_object(obj: &mut GameObject, back_face: BackFaceData) obj.color = back_face.color.clone(); obj.base_power = back_face.power; obj.base_toughness = back_face.toughness; + obj.layer_base_power = back_face.power; + obj.layer_base_toughness = back_face.toughness; obj.base_name = back_face.name.clone(); obj.base_loyalty = back_face.loyalty; obj.base_printed_loyalty = back_face.printed_loyalty; @@ -667,6 +671,10 @@ pub fn apply_copiable_values( obj.card_types = values.card_types.clone(); obj.power = values.power; obj.toughness = values.toughness; + // CR 613.1a + CR 613.4b: a copy replaces the copiable baseline seen by + // subsequent layer-7b/base-power reads until the next layer reset. + obj.layer_base_power = values.power; + obj.layer_base_toughness = values.toughness; obj.loyalty = values.loyalty; obj.printed_loyalty = values.printed_loyalty; obj.keywords = values.keywords.clone(); @@ -715,6 +723,8 @@ pub fn install_copiable_values_as_base(obj: &mut GameObject, values: &CopiableVa obj.base_card_types = values.card_types.clone(); obj.base_power = values.power; obj.base_toughness = values.toughness; + obj.layer_base_power = values.power; + obj.layer_base_toughness = values.toughness; obj.base_loyalty = values.loyalty; obj.base_printed_loyalty = values.printed_loyalty; obj.base_keywords = values.keywords.clone(); diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 5846362118..dd7f69e2bd 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -558,6 +558,9 @@ pub(crate) fn quantity_expr_uses_recipient(expr: &QuantityExpr) -> bool { | QuantityRef::Power { scope: ObjectScope::Recipient, } + | QuantityRef::BasePower { + scope: ObjectScope::Recipient, + } | QuantityRef::Toughness { scope: ObjectScope::Recipient, } @@ -579,6 +582,9 @@ pub(crate) fn quantity_expr_uses_recipient(expr: &QuantityExpr) -> bool { QuantityRef::Power { scope: ObjectScope::CostPaidObject, } + | QuantityRef::BasePower { + scope: ObjectScope::CostPaidObject, + } | QuantityRef::Toughness { scope: ObjectScope::CostPaidObject, } @@ -651,6 +657,7 @@ pub(crate) fn quantity_expr_uses_resolution_only_object_scope(expr: &QuantityExp QuantityExpr::Fixed { .. } => false, QuantityExpr::Ref { qty } => match qty { QuantityRef::Power { scope } + | QuantityRef::BasePower { scope } | QuantityRef::Toughness { scope } | QuantityRef::ObjectManaValue { scope } | QuantityRef::ObjectColorCount { scope } @@ -691,6 +698,7 @@ pub(crate) fn quantity_expr_contains_scope(expr: &QuantityExpr, scope: ObjectSco fn ref_contains_scope(qty: &QuantityRef, scope: ObjectScope) -> bool { match qty { QuantityRef::Power { scope: s } + | QuantityRef::BasePower { scope: s } | QuantityRef::Toughness { scope: s } | QuantityRef::ObjectManaValue { scope: s } | QuantityRef::ObjectColorCount { scope: s } @@ -836,6 +844,7 @@ pub(crate) fn quantity_expr_missing_resolution_only_referent( QuantityExpr::Fixed { .. } => false, QuantityExpr::Ref { qty } => match qty { QuantityRef::Power { scope } + | QuantityRef::BasePower { scope } | QuantityRef::Toughness { scope } | QuantityRef::ObjectManaValue { scope } | QuantityRef::ObjectColorCount { scope } @@ -953,6 +962,7 @@ fn quantity_ref_uses_unspent_mana(qty: &QuantityRef) -> bool { | QuantityRef::TargetControllerCounter { .. } | QuantityRef::Variable { .. } | QuantityRef::Power { .. } + | QuantityRef::BasePower { .. } | QuantityRef::Intensity { .. } | QuantityRef::Toughness { .. } | QuantityRef::ObjectManaValue { .. } @@ -1289,6 +1299,7 @@ fn quantity_ref_uses_object_count(qty: &QuantityRef) -> bool { | QuantityRef::TargetControllerCounter { .. } | QuantityRef::Variable { .. } | QuantityRef::Power { .. } + | QuantityRef::BasePower { .. } | QuantityRef::Intensity { .. } | QuantityRef::Toughness { .. } | QuantityRef::ObjectManaValue { .. } @@ -1548,10 +1559,16 @@ fn quantity_ref_characteristic_reads(qty: &QuantityRef, depth: u32) -> Character } // ---- Single-object characteristic reads. ---- - // CR 208.1 / CR 209.1. - QuantityRef::Power { .. } | QuantityRef::Toughness { .. } => { + // CR 208.1 / CR 209.1: power and toughness are single-object + // characteristic reads. + QuantityRef::Power { .. } + | QuantityRef::Toughness { .. } => { CharacteristicKinds::POWER_TOUGHNESS } + // CR 208.4b + CR 613.4b: BasePower reads the current base value after + // characteristic-defining and setting effects, before layer-7c + // modifications and counters. + QuantityRef::BasePower { .. } => CharacteristicKinds::POWER_TOUGHNESS, // CR 607.2b: power of a card in exile, read the same way. QuantityRef::ExiledCardPower { .. } => CharacteristicKinds::POWER_TOUGHNESS, // CR 202.3 / CR 107.4a. @@ -1850,6 +1867,7 @@ fn entered_object_perturbs_quantity_ref( | QuantityRef::TargetControllerCounter { .. } | QuantityRef::Variable { .. } | QuantityRef::Power { .. } + | QuantityRef::BasePower { .. } | QuantityRef::Intensity { .. } | QuantityRef::Toughness { .. } | QuantityRef::ObjectManaValue { .. } @@ -3593,6 +3611,17 @@ fn resolve_ref( |obj| obj.power, |lki| lki.power, ), + // CR 208.4b + CR 613.4a-b: base power is the current layer-7a/7b + // value, before counters and other power-modifying effects in layer 7c. + QuantityRef::BasePower { scope } => resolve_object_pt( + state, + *scope, + ctx, + targets, + ability, + |obj| obj.layer_base_power.or(obj.base_power), + |lki| lki.base_power, + ), // Digital-only Alchemy: read the object's current intensity. The reader // is the source itself (a spell on the stack or a permanent reading its // own intensity), so the live object carries it; LKI does not track diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index e41f7e7ba0..4609c35287 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -10421,6 +10421,7 @@ fn quantity_ref_binding_diverges(qty: &QuantityRef) -> bool { match qty { QuantityRef::CountersOn { scope, .. } | QuantityRef::Power { scope } + | QuantityRef::BasePower { scope } | QuantityRef::Intensity { scope } | QuantityRef::Toughness { scope } | QuantityRef::ObjectManaValue { scope } @@ -13871,6 +13872,7 @@ fn quantity_ref_refs_cost_paid_object(qty: &QuantityRef) -> bool { match qty { // Object-axis refs: read the cost-paid object iff scoped to it. QuantityRef::Power { scope } + | QuantityRef::BasePower { scope } | QuantityRef::Toughness { scope } | QuantityRef::Intensity { scope } | QuantityRef::ObjectManaValue { scope } diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index ad65f3ab13..85387682af 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -213,8 +213,8 @@ pub(crate) fn apply_zone_exit_cleanup( toughness: obj.toughness, // CR 208.4b + CR 613.4b: Capture the layer-7b base values so // base-scope P/T look-back filters read the base, not current. - base_power: obj.base_power, - base_toughness: obj.base_toughness, + base_power: obj.layer_base_power.or(obj.base_power), + base_toughness: obj.layer_base_toughness.or(obj.base_toughness), // CR 202.3d + CR 709.4b: this LKI is captured on leaving the // battlefield or exile (off the stack), so a split card records // its combined mana value and colors (no-op for single-face and diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 866818ac2e..ecf6908e77 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -80,9 +80,10 @@ use super::{ parse_spells_cast_this_way_graveyard_replacement_rider, publishes_aggregate_set_from_resolution, publishes_exiled_cause_at_resolution, publishes_tracked_set_from_resolution, rebind_tracked_aggregate_to_chain_set, - retarget_counter_additional_cost_to_target, rewrite_grant_parent_to_filter, - rewrite_parent_targets_to_tracked_set, rewrite_rounding_mode, rewrite_that_type_mana_instead, - stamp_delayed_returns, try_fold_token_repeat_into_count, wire_optional_cast_decline_fallback, + resolve_difference_anaphor_in_ability, retarget_counter_additional_cost_to_target, + rewrite_grant_parent_to_filter, rewrite_parent_targets_to_tracked_set, rewrite_rounding_mode, + rewrite_that_type_mana_instead, stamp_delayed_returns, try_fold_token_repeat_into_count, + wire_optional_cast_decline_fallback, }; /// CR 601.2c: True when the assembled head chose one or more players at @@ -1591,6 +1592,19 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { let d = &mut defs[bound_index]; { let mut else_def = else_def.clone(); + // CR 608.2c: both branches of a conditional inherit + // comparison-derived "the difference" from the same + // antecedent condition. The else chain is parsed + // independently, so bind its deferred placeholder + // before attaching it to the conditional definition. + let resolution = d + .condition + .as_ref() + .and_then(super::conditions::difference_expr); + resolve_difference_anaphor_in_ability( + &mut else_def, + resolution.as_ref(), + ); // CR 608.2c: when the gated clause acts on the // source (`SelfRef`), the else clause's "it" anaphor // is the same source — rebind its `ParentTarget` diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 912ab9920b..64369cc649 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -1537,6 +1537,7 @@ fn quantity_ref_reads_other_revealed_card(qty: &QuantityRef) -> bool { let scope = match qty { QuantityRef::ObjectManaValue { scope } | QuantityRef::Power { scope } + | QuantityRef::BasePower { scope } | QuantityRef::Toughness { scope } | QuantityRef::ObjectColorCount { scope } | QuantityRef::ObjectNameWordCount { scope } @@ -3913,13 +3914,31 @@ fn is_chain_veil_for_each_grant(lower: &str) -> bool { } pub(crate) fn strip_for_each_prefix(text: &str) -> (Option, String) { + let (repeat_for, _, rest) = strip_for_each_prefix_with_difference(text); + (repeat_for, rest) +} + +/// CR 608.2c + CR 208.4b: Peel a leading `for each` prefix while preserving +/// comparison provenance from the parser product. The optional binding is +/// produced only by the dedicated controller-scoped `PowerExceedsBase` parser +/// arm; it is not inferred by searching an arbitrary filter tree later. +pub(crate) fn strip_for_each_prefix_with_difference( + text: &str, +) -> (Option, Option, String) { let lower = text.to_lowercase(); if let Some(((), rest)) = nom_on_lower(text, &lower, |i| value((), tag("for each ")).parse(i)) { let rest_lower = &lower[text.len() - rest.len()..]; if let Ok((remainder, clause)) = terminated(take_until(", "), tag::<_, _, OracleError<'_>>(", ")).parse(rest_lower) { - if let Some(qty) = parse_for_each_clause(clause) { + let parsed_comparison = nom_quantity::parse_for_each_clause_ref_with_difference(clause) + .ok() + .and_then(|(rest, parsed)| rest.is_empty().then_some(parsed)); + let parsed_clause = parsed_comparison + .clone() + .map(|(qty, difference)| (qty, Some(difference))) + .or_else(|| parse_for_each_clause(clause).map(|qty| (qty, None))); + if let Some((qty, difference)) = parsed_clause { // CR 105.1: "for each color among [X], add one mana of that color" // must NOT be split into (repeat_for, "add one mana of that color"). // The "that color" anaphors the per-iteration color, not the @@ -3933,11 +3952,11 @@ pub(crate) fn strip_for_each_prefix(text: &str) -> (Option, String .trim() .eq_ignore_ascii_case("add one mana of that color") { - return (None, text.to_string()); + return (None, None, text.to_string()); } let mut copy_ctx = ParseContext::default(); if parse_for_each_object_copy_parts(text, &lower, &mut copy_ctx).is_some() { - return (None, text.to_string()); + return (None, None, text.to_string()); } // CR 606.3: The Chain Veil's "For each planeswalker you control, // you may activate one of its loyalty abilities once this turn..." @@ -3947,14 +3966,44 @@ pub(crate) fn strip_for_each_prefix(text: &str) -> (Option, String // a repeat count. Bailing out keeps the residual text intact so // the imperative dispatch can recognize the full pattern. if is_chain_veil_for_each_grant(&lower) { - return (None, text.to_string()); + return (None, None, text.to_string()); } let offset = text.len() - remainder.len(); - return (Some(QuantityExpr::Ref { qty }), text[offset..].to_string()); + return ( + Some(QuantityExpr::Ref { qty }), + difference, + text[offset..].to_string(), + ); } } } - (None, text.to_string()) + (None, None, text.to_string()) +} + +#[cfg(test)] +mod difference_binding_tests { + use super::strip_for_each_prefix_with_difference; + + #[test] + fn comparison_parser_product_carries_difference_provenance() { + let (repeat_for, difference, rest) = strip_for_each_prefix_with_difference( + "for each creature you control with power greater than that creature's base power, put a counter", + ); + assert!(repeat_for.is_some()); + assert!(difference.is_some()); + assert_eq!(rest, "put a counter"); + } + + #[test] + fn nested_not_and_or_properties_do_not_bind_difference() { + for text in [ + "for each creature you control with not power greater than that creature's base power, put a counter", + "for each creature you control with power greater than that creature's base power or flying, put a counter", + ] { + let (_, difference, _) = strip_for_each_prefix_with_difference(text); + assert!(difference.is_none(), "nested property must not bind: {text}"); + } + } } /// CR 705.2: Strip the redundant `"for each flip you won, "` (Mirror March) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index a3d0d92570..c1e5b1c99d 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -20459,13 +20459,16 @@ fn rebind_source_ref(qty: &mut QuantityRef, target: ObjectScope, rebind: SourceR if matches!(rebind, SourceRefRebind::PowerOrToughness) && !matches!( qty, - QuantityRef::Power { .. } | QuantityRef::Toughness { .. } + QuantityRef::Power { .. } + | QuantityRef::BasePower { .. } + | QuantityRef::Toughness { .. } ) { return; } let scope = match qty { QuantityRef::Power { scope } + | QuantityRef::BasePower { scope } | QuantityRef::Toughness { scope } | QuantityRef::ObjectManaValue { scope } | QuantityRef::ObjectColorCount { scope } @@ -22158,6 +22161,7 @@ pub(super) fn rebind_target_subject_object_scope(expr: &mut QuantityExpr) { QuantityExpr::Ref { qty } => { let scope = match qty { QuantityRef::Power { scope } + | QuantityRef::BasePower { scope } | QuantityRef::Toughness { scope } | QuantityRef::ObjectManaValue { scope } | QuantityRef::ObjectColorCount { scope } @@ -22199,6 +22203,7 @@ pub(super) fn rebind_target_subject_object_scope(expr: &mut QuantityExpr) { fn rebind_anaphoric_ref(qty: &mut QuantityRef, target: ObjectScope) { let scope = match qty { QuantityRef::Power { scope } + | QuantityRef::BasePower { scope } | QuantityRef::Toughness { scope } | QuantityRef::ObjectManaValue { scope } | QuantityRef::ObjectColorCount { scope } @@ -28499,16 +28504,20 @@ pub(crate) fn resolve_difference_anaphor_in_ability( } fn resolve_difference_anaphor_in_effect(effect: &mut Effect, bound: Option<&QuantityExpr>) { - // Recurse into the single-`Box` wrapper (the draw-replacement - // substitute) so a placeholder nested inside it is reached. This is the only - // `Effect` variant that wraps a heterogeneous sub-`Effect`; every other - // nesting is via `AbilityDefinition` (`sub_ability`/`else_ability`), walked - // by the caller. - if let Effect::CreateDrawReplacement { - replacement_effect: inner, - } = effect - { - resolve_difference_anaphor_in_effect(inner, bound); + // Recurse into effect variants that carry a nested ability/effect so a + // placeholder inside the deferred body is reached. Ordinary ability-chain + // nesting (`sub_ability`/`else_ability`) is walked by the caller. + match effect { + Effect::CreateDrawReplacement { + replacement_effect: inner, + } => resolve_difference_anaphor_in_effect(inner, bound), + // CR 603.7a: a delayed trigger carries a complete ability definition; + // walk that definition so a comparison-derived binding reaches a + // deferred "the difference" in its eventual effect body. + Effect::CreateDelayedTrigger { effect: inner, .. } => { + resolve_difference_anaphor_in_ability(inner, bound) + } + _ => {} } // Only effects a count parser can emit the deferred placeholder onto ever @@ -32415,24 +32424,25 @@ pub(crate) fn parse_effect_chain_ir( // target permanent, put another counter of that kind on it or remove one // from it" — Dramatist's Puppet, Quarry Hauler), whose target and choice // would likewise be dropped by the generic strip. - let (repeat_for, text, for_each_reference_target) = if try_parse_proliferate_target(&text) - .is_some() - || try_parse_for_each_counter_kind_adjust_target(&text).is_some() - { - (None, text, None) - } else if let Some(stripped) = strip_redundant_flip_win_quantifier(&text) { - // CR 705.2: "for each flip you won, " (Mirror March) — the flip - // loop (`finish_until_lose`) already runs the win effect once per win, - // so the quantifier is redundant. Drop it (no `repeat_for`) so the bare - // copy clause reaches `CopyTokenOf` instead of an `Unimplemented` "for" - // fallback (#5966). - (None, stripped, None) - } else { - let reference_target = for_each_clause_target_controller_filter(&text); - let (repeat_for, text) = super::clause_shell::peel_for_each_prefix(&text); - let reference_target = repeat_for.as_ref().and(reference_target); - (repeat_for, text, reference_target) - }; + let (repeat_for, text, for_each_reference_target, repeat_for_difference) = + if try_parse_proliferate_target(&text).is_some() + || try_parse_for_each_counter_kind_adjust_target(&text).is_some() + { + (None, text, None, None) + } else if let Some(stripped) = strip_redundant_flip_win_quantifier(&text) { + // CR 705.2: "for each flip you won, " (Mirror March) — the flip + // loop (`finish_until_lose`) already runs the win effect once per win, + // so the quantifier is redundant. Drop it (no `repeat_for`) so the bare + // copy clause reaches `CopyTokenOf` instead of an `Unimplemented` "for" + // fallback (#5966). + (None, stripped, None, None) + } else { + let reference_target = for_each_clause_target_controller_filter(&text); + let (repeat_for, difference, text) = + lower::strip_for_each_prefix_with_difference(&text); + let reference_target = repeat_for.as_ref().and(reference_target); + (repeat_for, text, reference_target, difference) + }; let (text_without_where_x, local_where_x_expression) = { let text_where_x_lower = text.to_lowercase(); let (without_where_x, where_x_expression) = @@ -33350,7 +33360,10 @@ pub(crate) fn parse_effect_chain_ir( // before the trigger seam runs, breaking every difference-counter // trigger. A spell's difference operands always ride the same clause // (Hit the Mother Lode), so `Some(bound)` is the only case to handle. - if let Some(bound) = effective_condition.and_then(conditions::difference_expr) { + if let Some(bound) = effective_condition + .and_then(conditions::difference_expr) + .or(repeat_for_difference) + { resolve_difference_anaphor_in_effect(&mut clause.effect, Some(&bound)); if let Some(sub) = clause.sub_ability.as_deref_mut() { resolve_difference_anaphor_in_ability(sub, Some(&bound)); diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index c5d2876e59..46f0b8ba65 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -10510,6 +10510,69 @@ fn draw_cards_equal_to_hand_difference_from_limit() { } } +/// CR 608.2c: an `Otherwise` branch must inherit a comparison-derived +/// "the difference" from the same conditional definition as its then branch. +/// The else chain is parsed independently, so the assembly pass must replace +/// its deferred `Variable("difference")` with the antecedent's typed operands. +#[test] +fn otherwise_branch_binds_difference_from_antecedent_condition() { + let def = parse_effect_chain( + "If you have fewer than seven cards in hand, draw a card. Otherwise, draw cards equal to the difference.", + AbilityKind::Spell, + ); + + assert_eq!( + def.condition, + Some(AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::HandSize { + player: PlayerScope::Controller, + }, + }, + comparator: Comparator::LT, + rhs: QuantityExpr::Fixed { value: 7 }, + }), + "then branch must retain the comparison that establishes the difference" + ); + assert!(matches!(*def.effect, Effect::Draw { .. })); + + let else_branch = def + .else_ability + .as_deref() + .expect("Otherwise must attach to the comparison-gated draw"); + match else_branch.effect.as_ref() { + Effect::Draw { + count: QuantityExpr::Difference { left, right }, + target: TargetFilter::Controller, + } => { + assert_eq!( + **left, + QuantityExpr::Ref { + qty: QuantityRef::HandSize { + player: PlayerScope::Controller, + }, + } + ); + assert_eq!(**right, QuantityExpr::Fixed { value: 7 }); + } + other => panic!( + "Otherwise draw must contain the typed comparison-derived Difference, got {other:?}" + ), + } + assert!( + !matches!( + else_branch.effect.as_ref(), + Effect::Draw { + count: QuantityExpr::Ref { + qty: QuantityRef::Variable { name } + }, + .. + } if name == "difference" + ), + "Otherwise must not retain an unresolved difference placeholder" + ); +} + /// CR 121.1 + CR 402: "if that player has more cards in hand than you, draw cards /// equal to the difference" — cross-player difference. Anchor: Slithermuse. #[test] diff --git a/crates/engine/src/parser/oracle_nom/filter.rs b/crates/engine/src/parser/oracle_nom/filter.rs index 36ac8770d0..d546d4146d 100644 --- a/crates/engine/src/parser/oracle_nom/filter.rs +++ b/crates/engine/src/parser/oracle_nom/filter.rs @@ -319,18 +319,19 @@ pub(crate) fn parse_superlative_property_head( Ok((input, (function, property))) } -/// CR 208.1: Possessive pronoun introducing a creature's *own* stat in a -/// self-referential P/T comparison — "its" (singular subject) or "their" (plural -/// subject). Both refer to the candidate object itself, not the ability source. +/// CR 208.1: Possessive phrase introducing a creature's *own* stat in a +/// self-referential P/T comparison — "its", "their", or "that creature's". +/// All refer to the candidate object itself, not the ability source. fn parse_pt_possessive(input: &str) -> OracleResult<'_, &str> { - alt((tag("its"), tag("their"))).parse(input) + alt((tag("its "), tag("their "), tag("that creature's "))).parse(input) } /// CR 208.1: "toughness greater than power" → [`FilterProp::ToughnessGTPower`] /// and "power greater than base power" → [`FilterProp::PowerExceedsBase`]. /// These are the self-referential P/T comparisons (a creature's own stat vs its /// own other stat), distinct from the numeric/quantity-threshold comparisons the -/// rest of `parse_pt_comparison` handles. Accepts singular and plural possessives. +/// rest of `parse_pt_comparison` handles. Accepts pronoun and demonstrative +/// possessives. fn parse_self_referential_pt(input: &str) -> OracleResult<'_, FilterProp> { alt(( value( @@ -338,7 +339,7 @@ fn parse_self_referential_pt(input: &str) -> OracleResult<'_, FilterProp> { ( tag("toughness greater than "), parse_pt_possessive, - tag(" power"), + tag("power"), ), ), value( @@ -346,7 +347,7 @@ fn parse_self_referential_pt(input: &str) -> OracleResult<'_, FilterProp> { ( tag("power greater than "), parse_pt_possessive, - tag(" base power"), + tag("base power"), ), ), )) @@ -795,6 +796,16 @@ mod tests { assert_eq!(rest, ""); } + #[test] + fn test_parse_with_self_referential_base_power_demonstrative() { + // CR 208.4b: the demonstrative possessive names the candidate creature's + // base power, not the ability source's power. + let (rest, prop) = + parse_with_property("with power greater than that creature's base power").unwrap(); + assert_eq!(rest, ""); + assert_eq!(prop, FilterProp::PowerExceedsBase); + } + #[test] fn test_parse_pt_comparison_base_disjunction() { // CR 208.4b: "base power or toughness 1 or less" → AnyOf of two diff --git a/crates/engine/src/parser/oracle_nom/quantity.rs b/crates/engine/src/parser/oracle_nom/quantity.rs index c40b93c4d6..368da232db 100644 --- a/crates/engine/src/parser/oracle_nom/quantity.rs +++ b/crates/engine/src/parser/oracle_nom/quantity.rs @@ -4616,6 +4616,14 @@ fn parse_for_each_clause_ref_with_they_controller( // unconsumed remainder (Armorcraft Judge, High Sentinels of Arashin, // Inspiring Call). parse_for_each_controlled_type_with_counter, + // CR 208.1 + CR 208.4b + CR 109.4: "[other] you control with + // power greater than that creature's base power" — a controller-scoped + // count gated on the candidate's own current/base-power comparison. + // Delegate the predicate to `parse_with_property`, the same shared + // property combinator used by target filters and ordinary "with" clauses. + // This arm must precede the bare controller count, whose shorter + // "you control" prefix would strand the property suffix. + parse_for_each_controlled_type_with_property, // CR 109.4 + CR 702: "[other] you control with " — a // controller-scoped count gated on a keyword-presence predicate. Must // precede `parse_for_each_controlled_type`, whose bare " you control" @@ -6081,6 +6089,81 @@ fn parse_for_each_controlled_type_with_keyword(input: &str) -> OracleResult<'_, )) } +/// CR 208.1 + CR 208.4b + CR 109.4: Parse a controller-scoped count with any +/// shared property predicate after "with". This is intentionally broader than +/// the card that first needs it: extending the existing property axis keeps P/T +/// comparisons and future typed properties in the same for-each building block +/// as keyword and counter predicates. +fn parse_for_each_controlled_type_with_property(input: &str) -> OracleResult<'_, QuantityRef> { + let (rest, has_other) = + opt(alt((value((), tag("other ")), value((), tag("another "))))).parse(input)?; + let (rest, tf) = parse_type_filter_word(rest)?; + let (rest, _) = tag(" you").parse(rest)?; + let (rest, _) = opt(tag(" already")).parse(rest)?; + // Keep the separator after "control" so the shared property parser sees + // its own `with` dispatch token. Returning after the bare controller phrase + // would otherwise leave the comparison suffix unconsumed. + let (rest, _) = tag(" control ").parse(rest)?; + let (rest, property) = super::filter::parse_with_property(rest)?; + + let mut properties = Vec::new(); + if has_other.is_some() { + properties.push(FilterProp::Another); + } + properties.push(property); + + Ok(( + rest, + QuantityRef::ObjectCount { + filter: TargetFilter::Typed(TypedFilter { + type_filters: vec![tf], + controller: Some(ControllerRef::You), + properties, + }), + }, + )) +} + +/// CR 208.4b + CR 608.2c: Parse the one for-each comparison whose Oracle +/// operands establish the recipient-relative "the difference" binding. +/// +/// This is deliberately a parser product, not a later walk over `TargetFilter`: +/// compound filters such as `Not` and `Or` may contain the same property without +/// establishing that the comparison selected the repeated recipient. +pub(crate) fn parse_for_each_clause_ref_with_difference( + input: &str, +) -> OracleResult<'_, (QuantityRef, QuantityExpr)> { + let (rest, quantity) = parse_for_each_controlled_type_with_property(input)?; + let difference = match &quantity { + QuantityRef::ObjectCount { + filter: TargetFilter::Typed(TypedFilter { properties, .. }), + } => properties + .iter() + .find_map(difference_expr_for_direct_property), + _ => None, + } + .ok_or_else(|| oracle_err(input))?; + Ok((rest, (quantity, difference))) +} + +/// CR 208.4b + CR 608.2c: Only the direct comparison property emitted by the +/// dedicated parser arm establishes the recipient-relative difference. A +/// negated property or a disjunctive property is not equivalent provenance. +fn difference_expr_for_direct_property(property: &FilterProp) -> Option { + matches!(property, FilterProp::PowerExceedsBase).then(|| QuantityExpr::Difference { + left: Box::new(QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::Recipient, + }, + }), + right: Box::new(QuantityExpr::Ref { + qty: QuantityRef::BasePower { + scope: ObjectScope::Recipient, + }, + }), + }) +} + /// CR 115.1 + CR 707.10: "[other] [you control] [on the battlefield] that /// [the] spell could target" — Zada ("other creature you control that the spell /// could target"), Ink-Treader Nephilim ("other creature that spell could target"), @@ -7239,6 +7322,43 @@ mod tests { } } + /// CR 208.1 + CR 208.4b + CR 109.4: the shared property arm retains the + /// candidate-relative power/base-power predicate in a controller-scoped + /// for-each population. + #[test] + fn parse_for_each_controlled_type_with_base_power_property() { + let (rest, q) = parse_for_each_clause_ref( + "other creature you control with power greater than that creature's base power", + ) + .unwrap(); + assert_eq!(rest, ""); + match q { + QuantityRef::ObjectCount { + filter: TargetFilter::Typed(tf), + } => { + assert_eq!(tf.controller, Some(ControllerRef::You)); + assert!(tf.properties.contains(&FilterProp::Another)); + assert!(tf.properties.contains(&FilterProp::PowerExceedsBase)); + } + other => panic!("expected ObjectCount(Typed), got {other:?}"), + } + } + + /// CR 208.4b + CR 608.2c: nested negation and unrelated disjunction do not + /// establish the direct comparison provenance used by "the difference". + #[test] + fn nested_filter_properties_do_not_bind_difference() { + let negated = FilterProp::Not { + prop: Box::new(FilterProp::PowerExceedsBase), + }; + let unrelated_or = FilterProp::AnyOf { + props: vec![FilterProp::PowerExceedsBase, FilterProp::Token], + }; + assert!(difference_expr_for_direct_property(&negated).is_none()); + assert!(difference_expr_for_direct_property(&unrelated_or).is_none()); + assert!(difference_expr_for_direct_property(&FilterProp::PowerExceedsBase).is_some()); + } + /// CR 604.3 + CR 109.4: opponent-controlled and chosen-player CDA counts. #[test] fn parse_number_of_controlled_type_opponent_and_chosen_player_cda() { diff --git a/crates/engine/src/parser/oracle_static/keyword_grant.rs b/crates/engine/src/parser/oracle_static/keyword_grant.rs index 6fa5ee2740..7006be29ac 100644 --- a/crates/engine/src/parser/oracle_static/keyword_grant.rs +++ b/crates/engine/src/parser/oracle_static/keyword_grant.rs @@ -1647,6 +1647,11 @@ fn rebind_source_scope_to_recipient( } => QuantityRef::Power { scope: ObjectScope::Recipient, }, + QuantityRef::BasePower { + scope: ObjectScope::Source, + } => QuantityRef::BasePower { + scope: ObjectScope::Recipient, + }, QuantityRef::Toughness { scope: ObjectScope::Source, } => QuantityRef::Toughness { diff --git a/crates/engine/src/parser/oracle_static/shared.rs b/crates/engine/src/parser/oracle_static/shared.rs index c8baab27ca..9a45ec1f10 100644 --- a/crates/engine/src/parser/oracle_static/shared.rs +++ b/crates/engine/src/parser/oracle_static/shared.rs @@ -3273,6 +3273,11 @@ pub(crate) fn rebind_source_object_quantity_ref_to_recipient(qty: QuantityRef) - } => QuantityRef::Power { scope: ObjectScope::Recipient, }, + QuantityRef::BasePower { + scope: ObjectScope::Source, + } => QuantityRef::BasePower { + scope: ObjectScope::Recipient, + }, QuantityRef::Toughness { scope: ObjectScope::Source, } => QuantityRef::Toughness { diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index c4ea8b9985..2e4b8f6b96 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -2463,6 +2463,7 @@ fn remap_self_scope_to_event_source_in_quantity(expr: &mut QuantityExpr) { fn remap_self_scope_to_event_source_in_ref(qty: &mut QuantityRef) { let scope = match qty { QuantityRef::Power { scope } + | QuantityRef::BasePower { scope } | QuantityRef::Toughness { scope } | QuantityRef::ObjectManaValue { scope } | QuantityRef::ObjectColorCount { scope } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 300877f438..af8888eb33 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -6779,6 +6779,11 @@ pub enum QuantityRef { /// `EventContextSourcePower` (CR 608.2k: cost OR trigger-condition /// referent). Power { scope: ObjectScope }, + /// CR 208.4b + CR 613.4b: Base power of an object, scoped via + /// `ObjectScope`. This reads the value after characteristic-defining and + /// set effects (layers 7a–7b), before counters and other modifiers (layer + /// 7c), rather than the current post-layer power read by `Power`. + BasePower { scope: ObjectScope }, /// Digital-only Alchemy (no CR entry): the current `intensity` of an object, /// scoped via `ObjectScope`. Reads "X is [card]'s intensity" / /// "this spell's intensity" / "equal to its intensity". @@ -7547,6 +7552,7 @@ impl QuantityRef { | QuantityRef::TargetControllerCounter { .. } | QuantityRef::Variable { .. } | QuantityRef::Power { .. } + | QuantityRef::BasePower { .. } | QuantityRef::Intensity { .. } | QuantityRef::Toughness { .. } | QuantityRef::ObjectManaValue { .. } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 89dd15dfb1..94ca80489a 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -20370,8 +20370,11 @@ impl GameState { keywords: object.keywords.clone(), power: object.power, toughness: object.toughness, - base_power: object.base_power, - base_toughness: object.base_toughness, + // CR 208.4b + CR 613.4a-b: preserve current base P/T after + // characteristic-defining and setting effects for this event + // snapshot; counters and later modifiers are excluded. + base_power: object.layer_base_power.or(object.base_power), + base_toughness: object.layer_base_toughness.or(object.base_toughness), mana_value: object.effective_mana_value(), counters: object.counters.clone(), is_token: object.is_token, diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index a230fadf1f..f5ad992ae6 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -994,6 +994,7 @@ mod sliver_static_grants; mod smaug_noncombat_damage_treasure; mod snow_mana_production; mod sothera_supervoid_edict_reanimate; +mod sovereign_okinec_ahau; mod spark_double_as_enters; mod spear_of_bashenga_attacks_monarch_5249; mod special_action_x_runtime; diff --git a/crates/engine/tests/integration/sovereign_okinec_ahau.rs b/crates/engine/tests/integration/sovereign_okinec_ahau.rs new file mode 100644 index 0000000000..a8912f0ec6 --- /dev/null +++ b/crates/engine/tests/integration/sovereign_okinec_ahau.rs @@ -0,0 +1,169 @@ +//! Runtime coverage for Sovereign Okinec Ahau's member-driven attack trigger. +//! +//! Sovereign Okinec Ahau: "Whenever Sovereign Okinec Ahau attacks, for each +//! creature you control with power greater than that creature's base power, put +//! a number of +1/+1 counters on that creature equal to the difference." +//! +//! The scenario exercises the real Oracle parser and combat/trigger pipeline. +//! A pumped creature is the discriminating member: a layer-7c +2/+0 effect +//! makes current power 4 versus base power 2, producing two counters. An +//! unpumped creature remains at zero. The repeated body must rebind both the +//! ParentTarget recipient and the difference operands independently for each +//! member. +//! +//! CR references (verified against docs/MagicCompRules.txt): +//! - CR 508.1a: the active player chooses which creatures attack. +//! - CR 603.2: the attack event automatically triggers the ability. +//! - CR 608.2c: the controller follows the instructions in order, including the +//! per-member repeat and its counter instruction. +//! - CR 208.4b + CR 613.4a-b: base power includes layer-7a/7b set effects; +//! counters are applied afterward. +//! - CR 122.1a + CR 613.4c: +1/+1 counters modify a creature's power and +//! toughness in layer 7c. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::{ContinuousModification, TargetFilter}; +use engine::types::counter::CounterType; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; +use engine::types::StaticDefinition; + +use super::rules::run_combat; + +const SOVEREIGN_ORACLE: &str = "Ward {2}\nWhenever Sovereign Okinec Ahau attacks, for each creature you control with power greater than that creature's base power, put a number of +1/+1 counters on that creature equal to the difference."; + +fn plus_one_counters(runner: &GameRunner, id: ObjectId) -> u32 { + runner + .state() + .objects + .get(&id) + .and_then(|object| object.counters.get(&CounterType::Plus1Plus1).copied()) + .unwrap_or(0) +} + +#[test] +fn attacks_put_difference_counters_on_each_pumped_creature() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let sovereign = scenario + .add_creature(P0, "Sovereign Okinec Ahau", 3, 4) + .from_oracle_text(SOVEREIGN_ORACLE) + .id(); + + let pumped = { + let mut creature = scenario.add_creature(P0, "Pumped Creature", 2, 2); + creature.with_static_definition( + StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 2 }]), + ); + creature.id() + }; + let unpumped = scenario.add_creature(P0, "Unpumped Creature", 2, 2).id(); + let mut runner = scenario.build(); + + run_combat(&mut runner, vec![sovereign], vec![]); + runner.advance_until_stack_empty(); + + assert_eq!( + plus_one_counters(&runner, pumped), + 2, + "power 4 minus base power 2 must add two +1/+1 counters" + ); + assert_eq!( + plus_one_counters(&runner, unpumped), + 0, + "a creature whose power equals base power is not in the repeated set" + ); +} + +#[test] +fn attacks_use_the_layer_7b_base_power_not_printed_power() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let sovereign = scenario + .add_creature(P0, "Sovereign Okinec Ahau", 3, 4) + .from_oracle_text(SOVEREIGN_ORACLE) + .id(); + + // The base-P/T setter applies before the separate current-power modifier. + // The printed 1 is deliberately neither value. + scenario.add_enchantment_from_oracle( + P0, + "Base-Form Anthem", + "Creatures you control have base power and toughness 4/4.", + ); + scenario.add_enchantment_from_oracle(P0, "Power Anthem", "Creatures you control get +3/+0."); + let layered_creature = scenario.add_creature(P0, "Layered Creature", 1, 1).id(); + let second_layered_creature = { + let mut creature = scenario.add_creature(P0, "Second Layered Creature", 2, 2); + // This self static is a genuine layer-7c modifier: current 8/base 4 + // differs from the first recipient's current 7/base 4. + creature.with_static_definition( + StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]), + ); + creature.id() + }; + let opponent_creature = scenario.add_creature(P1, "Opponent Creature", 2, 2).id(); + let mut runner = scenario.build(); + + run_combat(&mut runner, vec![sovereign], vec![]); + runner.advance_until_stack_empty(); + + assert_eq!( + plus_one_counters(&runner, layered_creature), + 3, + "current power 7 minus layer-7b base power 4 must add three counters, not six from printed power 1" + ); + assert_eq!( + plus_one_counters(&runner, second_layered_creature), + 4, + "the second eligible creature must receive its own difference: 8 minus 4" + ); + assert_eq!( + plus_one_counters(&runner, sovereign), + 3, + "Sovereign is eligible under the same layer effects and must receive its own difference" + ); + assert_eq!( + plus_one_counters(&runner, opponent_creature), + 0, + "an opponent's creature is outside Sovereign's controlled-creature filter" + ); +} + +#[test] +fn sovereign_attack_does_not_count_itself_without_a_power_modifier() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let sovereign = scenario + .add_creature(P0, "Sovereign Okinec Ahau", 3, 4) + .from_oracle_text(SOVEREIGN_ORACLE) + .id(); + let eligible = { + let mut creature = scenario.add_creature(P0, "Eligible Attacker", 1, 1); + creature.with_static_definition( + StaticDefinition::continuous() + .affected(TargetFilter::SelfRef) + .modifications(vec![ContinuousModification::AddPower { value: 1 }]), + ); + creature.id() + }; + let mut runner = scenario.build(); + + run_combat(&mut runner, vec![sovereign, eligible], vec![]); + runner.advance_until_stack_empty(); + + assert_eq!( + plus_one_counters(&runner, sovereign), + 0, + "the source has no power/base-power difference and must not self-pump" + ); + assert_eq!( + plus_one_counters(&runner, eligible), + 1, + "an independently eligible attacking creature must receive its own difference" + ); +}