diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 35eb229199..7aa242ac0d 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -2241,17 +2241,13 @@ fn transient_granted_spell_keywords_for( if id != caster { continue; } - // CR 603.4 + CR 608.2h: mirror `transient_grants_static_mode_to_player`'s - // dual-condition gating exactly. - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !super::layers::transient_gate_conditions(tce).all(|condition| { + super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) + }) { + continue; } for modification in &tce.modifications { let ContinuousModification::GrantStaticAbility { definition } = modification else { diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index ceb819efee..49a00a4768 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -8,7 +8,8 @@ use std::collections::{HashMap, HashSet}; use crate::game::combat; use crate::game::game_object::GameObject; use crate::game::quantity::{ - counter_count_from_map, resolve_quantity, resolve_quantity_with_targets, + counter_count_from_map, quantity_expr_characteristic_reads_at, resolve_quantity, + resolve_quantity_with_targets, }; use crate::types::ability::{ ChoiceValue, ChosenAttribute, CombatRelation, CombatRelationSubject, ControllerRef, CountScope, @@ -31,6 +32,104 @@ use crate::types::proposed_event::{EtbTapState, ProposedEvent, TokenSpec}; use crate::types::statics::StaticMode; use crate::types::zones::Zone; +/// CR 613.1: The set of layer-writable characteristic kinds that a filter, +/// quantity expression or static condition READS — one bit per CR 613 sublayer +/// that can rewrite that kind, plus the copy-only mana cost. +/// +/// This is the shared currency of the entry-incremental flush gate's +/// read/write-kind relation (see `game::layers`): a live modification whose +/// write kinds are disjoint from every live read kind cannot flip any live +/// verdict, so the entry fast path stays sound. Both surfaces classify into +/// this one lattice, which is what makes the intersection meaningful. +/// +/// Granularity is exactly one CR 613 sublayer per kind, so the parameterization +/// axis stays inside CR 613's own taxonomy (no cross-section unification): +/// - [`Self::CONTROLLER`] — CR 613.1b (layer 2). CR 109.3 does not list +/// controller among an object's characteristics, but a control change moves +/// an object between controller-keyed populations, so the relation must track +/// it alongside the true characteristics. +/// - [`Self::NAME_TEXT`] — CR 613.1c (layer 3) + CR 612.8 (an effect that sets +/// an object's name is a text-changing effect). +/// - [`Self::CARD_TYPES`] — CR 613.1d (layer 4), which is card type, subtype +/// AND supertype; all three fold into one kind because CR 613.1d is one +/// sublayer. +/// - [`Self::COLOR`] — CR 613.1e (layer 5). +/// - [`Self::ABILITIES`] — CR 613.1f (layer 6). +/// - [`Self::POWER_TOUGHNESS`] — CR 613.1g (layer 7), sublayers CR 613.4a-d. +/// - [`Self::MANA_COST`] — no layer of its own; only copy effects rewrite it +/// (CR 707.9b). It is tracked because devotion (CR 700.5) and mana-value +/// populations key on it, so All-writers (copies) must intersect those reads. +/// +/// CRITICAL — this is NOT the question that +/// [`filter_prop_uses_object_population`] answers. That classifier answers +/// MEMBERSHIP-perturbation ("can another object entering the battlefield change +/// this verdict or this count"). This one answers CHARACTERISTIC-dependence +/// ("which layer-writable kinds does this verdict read"). They are siblings, +/// not duplicates: "the number of tapped permanents" USES the object population +/// but reads NO layer-writable kind, and this relation correctly ignores it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CharacteristicKinds(u8); + +impl CharacteristicKinds { + /// Reads (or writes) nothing that a continuous effect can rewrite. + pub(crate) const EMPTY: Self = Self(0); + /// CR 613.1b: layer 2, control-changing effects. + pub(crate) const CONTROLLER: Self = Self(1 << 0); + /// CR 613.1c + CR 612.8: layer 3, text-changing effects (names). + pub(crate) const NAME_TEXT: Self = Self(1 << 1); + /// CR 613.1d: layer 4, card type / subtype / supertype. + pub(crate) const CARD_TYPES: Self = Self(1 << 2); + /// CR 613.1e: layer 5, color-changing effects. + pub(crate) const COLOR: Self = Self(1 << 3); + /// CR 613.1f: layer 6, ability-adding and ability-removing effects. + pub(crate) const ABILITIES: Self = Self(1 << 4); + /// CR 613.1g + CR 613.4a-d: layer 7, power and/or toughness. + pub(crate) const POWER_TOUGHNESS: Self = Self(1 << 5); + /// CR 707.9b: copy-writable only; no layer of its own. + pub(crate) const MANA_COST: Self = Self(1 << 6); + /// Every kind — the conservative answer for any form whose reads or writes + /// cannot be determined structurally. Over-approximating here can only + /// over-escalate (a full re-evaluation is slower, never wrong). + pub(crate) const ALL: Self = Self(0b0111_1111); + + pub(crate) const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + pub(crate) const fn intersects(self, other: Self) -> bool { + self.0 & other.0 != 0 + } + + pub(crate) const fn intersection(self, other: Self) -> Self { + Self(self.0 & other.0) + } + + /// The kinds in `self` that are not in `other`. + pub(crate) const fn without(self, other: Self) -> Self { + Self(self.0 & !other.0) + } + + pub(crate) const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + + pub(crate) const fn is_empty(self) -> bool { + self.0 == 0 + } + + pub(crate) const fn is_all(self) -> bool { + self.contains(Self::ALL) + } +} + +/// Recursion budget for the characteristic-read walkers. +/// +/// Filters, quantity expressions and conditions form a finite OWNED tree (no +/// cycles are representable), so the walk always terminates; this cap only +/// bounds stack depth on pathologically nested hand-authored data. Overflow +/// yields [`CharacteristicKinds::ALL`], the conservative answer. +pub(crate) const CHARACTERISTIC_READ_DEPTH: u32 = 8; + /// True when the filter's matched SET depends on the population of objects on /// the battlefield — i.e. another object entering or leaving the battlefield can /// change whether a PRE-EXISTING object satisfies this filter. @@ -258,6 +357,412 @@ fn filter_prop_uses_object_population(prop: &FilterProp) -> bool { } } +/// CR 613.1: Which layer-writable characteristic kinds does this filter's +/// verdict read? +/// +/// EXHAUSTIVE and wildcard-free over `TargetFilter`, deliberately living beside +/// [`affected_filter_uses_object_population`] so that adding a variant forces +/// BOTH the membership decision and the characteristic decision at one seam. +/// See [`CharacteristicKinds`] for why these are two questions, not one. +pub(crate) fn target_filter_characteristic_reads(filter: &TargetFilter) -> CharacteristicKinds { + target_filter_characteristic_reads_at(filter, CHARACTERISTIC_READ_DEPTH) +} + +pub(crate) fn target_filter_characteristic_reads_at( + filter: &TargetFilter, + depth: u32, +) -> CharacteristicKinds { + let Some(depth) = depth.checked_sub(1) else { + return CharacteristicKinds::ALL; + }; + match filter { + TargetFilter::Not { filter: inner } => target_filter_characteristic_reads_at(inner, depth), + TargetFilter::Or { filters } | TargetFilter::And { filters } => { + filters.iter().fold(CharacteristicKinds::EMPTY, |acc, f| { + acc.union(target_filter_characteristic_reads_at(f, depth)) + }) + } + TargetFilter::Typed(TypedFilter { + type_filters, + controller, + properties, + }) => { + let mut kinds = CharacteristicKinds::EMPTY; + // CR 613.1d: every type constraint reads the layer-4 typeline. + for tf in type_filters { + kinds = kinds.union(type_filter_characteristic_reads(tf)); + } + // CR 613.1b: a controller-scoped filter gains and loses members when + // layer 2 rewrites control. + if controller.is_some() { + kinds = kinds.union(CharacteristicKinds::CONTROLLER); + } + for prop in properties { + if kinds.is_all() { + break; + } + kinds = kinds.union(filter_prop_characteristic_reads_at(prop, depth)); + } + kinds + } + // Payload-bearing object references: recurse into the embedded filter. + TargetFilter::TrackedSetFiltered { filter: inner, .. } => { + target_filter_characteristic_reads_at(inner, depth) + } + TargetFilter::ChosenDamageSource { filter: inner, .. } => { + inner.as_ref().map_or(CharacteristicKinds::EMPTY, |f| { + target_filter_characteristic_reads_at(f, depth) + }) + } + // CR 613.1b: stack-ability reference scoped by controller. + TargetFilter::StackAbility { .. } => CharacteristicKinds::CONTROLLER, + // CR 613.1b + CR 613.1d: parse-layer sugar for "that player and the + // permanents of this type they control"; lowered before object matching, + // but classified truthfully. + TargetFilter::ControllerAndControlledPermanents { .. } => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::CONTROLLER) + } + // CR 201.2: compares the live `name` field, which layer 3 writes. + TargetFilter::Named { .. } => CharacteristicKinds::NAME_TEXT, + // Fixed object / player references, zone anchors and ledger lookups: the + // referent is picked by identity, not by any layer-written + // characteristic. Enumerated explicitly (no wildcard) so a future variant + // is forced through this classification. CR 108.3: `Owner` is fixed at + // game start and is not layer-writable. + TargetFilter::None + | TargetFilter::Any + | TargetFilter::Player + | TargetFilter::Controller + | TargetFilter::Opponent + | TargetFilter::SelfRef + | TargetFilter::SourceOrPaired + | TargetFilter::StackSpell + | TargetFilter::SpecificObject { .. } + | TargetFilter::SpecificPlayer { .. } + | TargetFilter::PlayerWhoChoseLabel { .. } + | TargetFilter::Neighbor { .. } + | TargetFilter::ScopedPlayer + | TargetFilter::AttachedTo + | TargetFilter::LastCreated + | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged + | TargetFilter::CostPaidObject + | TargetFilter::ChosenCard + | TargetFilter::TrackedSet { .. } + | TargetFilter::ExiledBySource + | TargetFilter::ExiledCardByIndex { .. } + | TargetFilter::TriggeringSpellController + | TargetFilter::TriggeringSpellOwner + | TargetFilter::TriggeringSourceController + | TargetFilter::TriggeringPlayer + | TargetFilter::TriggeringSource + | TargetFilter::EventTarget + | TargetFilter::ParentTarget + | TargetFilter::ParentTargetSlot { .. } + | TargetFilter::ParentTargetController + | TargetFilter::ParentTargetOwner + | TargetFilter::SourceChosenPlayer + | TargetFilter::OriginalController + | TargetFilter::OriginalSource + | TargetFilter::PostReplacementSourceController + | TargetFilter::PostReplacementDamageSource + | TargetFilter::PostReplacementDamageTarget + | TargetFilter::PostReplacementDamageTargetOwner + | TargetFilter::DefendingPlayer + | TargetFilter::HasChosenName + | TargetFilter::Owner + | TargetFilter::GrantingObject + | TargetFilter::AllPlayers => CharacteristicKinds::EMPTY, + } +} + +/// CR 613.1d + CR 613.1f: a type constraint reads the layer-4 typeline; a +/// SUBTYPE constraint additionally reads layer-6 abilities, because Changeling +/// (CR 702.73a) makes an object every creature type and the live check reads the +/// keyword set (`subtype_matches_with_changeling`). +fn type_filter_characteristic_reads(tf: &TypeFilter) -> CharacteristicKinds { + match tf { + TypeFilter::Subtype(_) => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::ABILITIES) + } + TypeFilter::Non(inner) => type_filter_characteristic_reads(inner), + TypeFilter::AnyOf(inners) => inners.iter().fold(CharacteristicKinds::EMPTY, |acc, t| { + acc.union(type_filter_characteristic_reads(t)) + }), + TypeFilter::Creature + | TypeFilter::Land + | TypeFilter::Artifact + | TypeFilter::Enchantment + | TypeFilter::Instant + | TypeFilter::Sorcery + | TypeFilter::Planeswalker + | TypeFilter::Battle + | TypeFilter::Kindred + | TypeFilter::Permanent + | TypeFilter::Card + | TypeFilter::Any => CharacteristicKinds::CARD_TYPES, + } +} + +/// CR 603.4: Which characteristic a "shares a quality with" comparison reads. +/// Shared by the `FilterProp::SharesQuality` arm and by +/// `QuantityRef::ObjectCountBySharedQuality` / `ObjectCountDistinct`, which +/// group objects on the same quality vocabulary. +pub(crate) fn shared_quality_characteristic_reads(quality: &SharedQuality) -> CharacteristicKinds { + match quality { + // CR 201.2: name comparison. + SharedQuality::Name => CharacteristicKinds::NAME_TEXT, + // CR 202.3: mana value is derived from the mana cost. + SharedQuality::ManaValue => CharacteristicKinds::MANA_COST, + // CR 208.1 / CR 209.1. + SharedQuality::Power | SharedQuality::Toughness | SharedQuality::TotalPowerToughness => { + CharacteristicKinds::POWER_TOUGHNESS + } + // CR 105.1. + SharedQuality::Color => CharacteristicKinds::COLOR, + // CR 205.3m + CR 702.73a: creature types see through Changeling, which + // is a layer-6 ability. + SharedQuality::CreatureType => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::ABILITIES) + } + // CR 205.2 / CR 205.3. + SharedQuality::CardType | SharedQuality::LandType | SharedQuality::PermanentType => { + CharacteristicKinds::CARD_TYPES + } + } +} + +/// CR 613.1: EXHAUSTIVE, wildcard-free leaf classifier for +/// [`target_filter_characteristic_reads`] — the characteristic-dependence twin +/// of [`filter_prop_uses_object_population`]. Adding a `FilterProp` variant +/// forces a decision here. +/// +/// A prop that carries a nested `TargetFilter` or `QuantityExpr` unions the +/// payload's kinds ON TOP of its own intrinsic reads; a prop that carries a +/// `ControllerRef` unions [`CharacteristicKinds::CONTROLLER`], because layer 2 +/// can move objects across the scope the prop is asking about. +fn filter_prop_characteristic_reads_at(prop: &FilterProp, depth: u32) -> CharacteristicKinds { + let Some(depth) = depth.checked_sub(1) else { + return CharacteristicKinds::ALL; + }; + match prop { + // ---- CR 613.1f (layer 6): keyword and ability reads. ---- + FilterProp::WithKeyword { .. } + | FilterProp::HasKeywordKind { .. } + | FilterProp::WithoutKeyword { .. } + | FilterProp::WithoutKeywordKind { .. } + // CR 605.1 + CR 113.1: both read the live ability set. + | FilterProp::HasManaAbility + | FilterProp::HasNoAbilities + // CR 602.1: activation costs live on the object's abilities. + | FilterProp::HasXInActivationCost => CharacteristicKinds::ABILITIES, + // CR 303.4 + CR 702.5: "could enchant" reads the source's own Enchant + // ability (layer 6) and the referenced object through the inner filter. + FilterProp::CanEnchant { target } => CharacteristicKinds::ABILITIES + .union(target_filter_characteristic_reads_at(target, depth)), + // CR 302.6 + CR 702.10: haste is a keyword read; the creature check is a + // typeline read; and the `summoning_sick` continuity fallback is re-armed + // by every layer-2 control change (CR 613.1b). + FilterProp::HasHasteOrControlledSinceTurnBegan => CharacteristicKinds::CARD_TYPES + .union(CharacteristicKinds::ABILITIES) + .union(CharacteristicKinds::CONTROLLER), + + // ---- CR 613.1d (layer 4): typeline reads. ---- + FilterProp::HasSupertype { .. } + | FilterProp::NotSupertype { .. } + | FilterProp::Historic + | FilterProp::NotHistoric + | FilterProp::IsChosenCardType + // CR 303.4 + CR 301.5: both read the attachment's subtype (Aura / + // Equipment) to decide the relationship. + | FilterProp::EnchantedBy + | FilterProp::EquippedBy => CharacteristicKinds::CARD_TYPES, + // CR 205.3m + CR 702.73a: creature-type reads see through Changeling, so + // they read layer 6 as well as layer 4. + FilterProp::IsChosenCreatureType | FilterProp::SharesCreatureTypeWithCommander => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::ABILITIES) + } + // CR 205.3m + CR 701.23a: whole-zone creature-type tally, scoped to a + // player (CR 613.1b). + FilterProp::MostPrevalentCreatureTypeIn { .. } => CharacteristicKinds::CARD_TYPES + .union(CharacteristicKinds::ABILITIES) + .union(CharacteristicKinds::CONTROLLER), + // CR 310 + CR 613.1b: the Battle's protector is read by type and by + // controller scope. + FilterProp::ProtectorMatches { .. } + // CR 303.4 + CR 301.5: attachment subtype plus the attachment's + // controller scope. + | FilterProp::HasAttachment { .. } + | FilterProp::HasAnyAttachmentOf { .. } + // CR 700.9: "modified" reads counters, Equipment and Auras controlled by + // the permanent's controller. + | FilterProp::Modified => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::CONTROLLER) + } + + // ---- CR 613.1e (layer 5): color reads. ---- + FilterProp::HasColor { .. } + | FilterProp::NotColor { .. } + | FilterProp::IsChosenColor + | FilterProp::ColorCount { .. } => CharacteristicKinds::COLOR, + // CR 205.2 + CR 608.2c: the transient card predicate is matched on both + // the card types and the color of the candidate. + FilterProp::MatchesLastChosenCardPredicate => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::COLOR) + } + + // ---- CR 613.1c (layer 3): name reads. ---- + FilterProp::SameName | FilterProp::SameNameAsParentTarget => CharacteristicKinds::NAME_TEXT, + // CR 201.2 + CR 613.1f: `Named` also matches through the live + // `StaticMode::CountsAsNamed` aliases, which are layer-6 statics. + FilterProp::Named { .. } => { + CharacteristicKinds::NAME_TEXT.union(CharacteristicKinds::ABILITIES) + } + // CR 201.2a: whole-board name tally, optionally scoped by controller. + FilterProp::NameMatchesAnyPermanent { .. } => { + CharacteristicKinds::NAME_TEXT.union(CharacteristicKinds::CONTROLLER) + } + // CR 201.2: names of the permanents the evaluating controller controls + // that match the inner filter — name, controller scope, and the inner + // filter's own kinds. + FilterProp::DifferentNameFrom { filter } => CharacteristicKinds::NAME_TEXT + .union(CharacteristicKinds::CONTROLLER) + .union(target_filter_characteristic_reads_at(filter, depth)), + + // ---- CR 613.1g (layer 7): power/toughness reads. ---- + // CR 208 + CR 613.4b: `Base` scope reads base P/T, which layers 7a/7b + // still write, so both scopes read this kind. + FilterProp::PtComparison { value, .. } => CharacteristicKinds::POWER_TOUGHNESS + .union(quantity_expr_characteristic_reads_at(value, depth)), + FilterProp::PowerGTSource + | FilterProp::ToughnessGTPower + | FilterProp::PowerExceedsBase => CharacteristicKinds::POWER_TOUGHNESS, + + // ---- CR 707.9b: mana-cost reads (copy-writable only). ---- + FilterProp::Cmc { value, .. } => CharacteristicKinds::MANA_COST + .union(quantity_expr_characteristic_reads_at(value, depth)), + FilterProp::ManaValueParity { .. } + | FilterProp::ManaSymbolCount { .. } + | FilterProp::ManaCostIn { .. } + | FilterProp::HasXInManaCost => CharacteristicKinds::MANA_COST, + + // ---- Structural recursion. ---- + // CR 122.1: the counter count itself is not layer-written; only the + // threshold expression can read characteristics. + FilterProp::Counters { count, .. } => quantity_expr_characteristic_reads_at(count, depth), + FilterProp::AnyOf { props } => props.iter().fold(CharacteristicKinds::EMPTY, |acc, p| { + if acc.is_all() { + acc + } else { + acc.union(filter_prop_characteristic_reads_at(p, depth)) + } + }), + // CR 608.2c: negation does not change WHICH state the inner prop reads. + FilterProp::Not { prop } => filter_prop_characteristic_reads_at(prop, depth), + // CR 115.9b/c: the stack entry's targets are matched by the inner filter. + FilterProp::TargetsOnly { filter } | FilterProp::Targets { filter } => { + target_filter_characteristic_reads_at(filter, depth) + } + // CR 603.4: the shared quality names exactly which characteristic is + // compared; the reference set contributes its own filter's kinds. + FilterProp::SharesQuality { + quality, reference, .. + } => { + let quality_kinds = shared_quality_characteristic_reads(quality); + reference.as_ref().map_or(quality_kinds, |r| { + quality_kinds.union(target_filter_characteristic_reads_at(r, depth)) + }) + } + + // ---- CR 613.1b (layer 2): controller-scoped predicates. ---- + // Each reads the matched object's live controller, or scopes a ledger + // lookup by a `ControllerRef` that layer 2 can move objects across. + FilterProp::ControllerChoseLabel { .. } + | FilterProp::Attacking { .. } + | FilterProp::AttackedThisTurn { .. } + // CR 108.3 fixes the RIGHT operand (the matched object's owner) at game + // start, but this prop is a two-operand relation: the `ControllerRef` + // LEFT operand resolves against the effect source's live controller + // (CR 109.5 — "you" on a static ability is the current controller of the + // object it is on), and layer 2 rewrites that (CR 613.1b). An immutable + // right operand does not make the relation immutable. + | FilterProp::Owned { .. } + // CR 302.6: reads the `summoning_sick` continuity flag, which layer 2 + // re-arms for every permanent whose controller changed (CR 613.1b). + | FilterProp::ControlledContinuouslySinceTurnBegan + | FilterProp::CountersPutOnThisTurn { .. } => CharacteristicKinds::CONTROLLER, + // CR 702.95e: a pair breaks when either half changes controller (layer 2, + // CR 613.1b) or stops being a creature (layer 4, CR 613.1d), so the + // unpaired verdict reads both kinds. + FilterProp::Unpaired => { + CharacteristicKinds::CONTROLLER.union(CharacteristicKinds::CARD_TYPES) + } + + // ---- Undeterminable: conservatively every kind. ---- + // CR 109.4: an arbitrary player predicate over the object's controller + // can read anything about the boards those players control. + FilterProp::ControllerMatches { .. } + // CR 115.1 + CR 707.10: evaluates the triggering spell's OWN target + // filter, which is not reachable from this AST node. + | FilterProp::CouldBeTargetedByTriggeringSpell => CharacteristicKinds::ALL, + // CR 109.1: identity exclusion. Against the ability's own parent target + // this is pure object identity and reads nothing; against any other + // reference the excluded set is filter-derived and could be anything. + FilterProp::DistinctFrom { reference } => match reference.as_ref() { + TargetFilter::ParentTarget => CharacteristicKinds::EMPTY, + _ => CharacteristicKinds::ALL, + }, + + // ---- Reads no layer-writable characteristic. ---- + // Token identity, zone, combat state, per-object designations, per-turn + // ledgers, stack shape, and the fail-closed unparsed leaf. Enumerated + // explicitly (no wildcard). + FilterProp::Token + | FilterProp::NonToken + | FilterProp::RepresentedByCard + | FilterProp::WasPlayed + | FilterProp::Blocking + | FilterProp::BlockingSource + | FilterProp::CombatRelation { .. } + | FilterProp::Unblocked + | FilterProp::AttackingAlone + | FilterProp::BlockingAlone + | FilterProp::Tapped + | FilterProp::Untapped + | FilterProp::IsSaddled + | FilterProp::SaddledSource + | FilterProp::ConvokedSource + | FilterProp::Foretold + | FilterProp::HasAdventure + | FilterProp::WasKicked + | FilterProp::InZone { .. } + | FilterProp::AttachedToSource + | FilterProp::AttachedToRecipient + | FilterProp::Another + | FilterProp::OtherThanTriggerObject + | FilterProp::InTrackedSet { .. } + | FilterProp::Suspected + | FilterProp::Renowned + | FilterProp::Goaded + | FilterProp::InAnyZone { .. } + | FilterProp::WasDealtDamageThisTurn + | FilterProp::DealtDamageThisTurn + | FilterProp::EnteredThisTurn + | FilterProp::ZoneChangedThisTurn { .. } + | FilterProp::BlockedThisTurn + | FilterProp::AttackedOrBlockedThisTurn + | FilterProp::HasSingleTarget + | FilterProp::Modal + | FilterProp::FaceDown + | FilterProp::Transformed + // CR 903.3: commander designation is set at deck construction. + | FilterProp::IsCommander + // Unparsed leaf: evaluates fail-closed `false` for every object, so its + // verdict can never be flipped by any layer. + | FilterProp::Other { .. } => CharacteristicKinds::EMPTY, + } +} + /// CR 611.3a: ENTRY-AWARE narrowing for a population-sensitive AFFECTED FILTER. /// `affected_filter_uses_object_population` proves an effect's affected set *can* /// read board population; this proves a SPECIFIC entering object can actually @@ -13167,3 +13672,340 @@ mod tests { ); } } + +/// Building-block coverage for the characteristic-dependence classifier +/// (`filter_prop_characteristic_reads_at`). These pin the invariants the +/// classifier documents in prose, at the level of the primitive rather than of +/// any one card, so a future variant cannot drift into the wrong kind group. +#[cfg(test)] +mod characteristic_read_classification_tests { + use super::*; + use crate::types::ability::{AttachmentKind, SourceExclusion}; + + /// Wraps a single prop in the minimal `Typed` filter: no type filters and no + /// controller scope, so the reported kinds come from the prop alone. + fn only(prop: FilterProp) -> TargetFilter { + TargetFilter::Typed(TypedFilter { + properties: vec![prop], + ..TypedFilter::default() + }) + } + + /// CR 613.1b: does this `FilterProp` scope its verdict by a `ControllerRef`? + /// + /// EXHAUSTIVE and wildcard-free, which is the whole point: adding a + /// `FilterProp` variant fails to compile here until it is classified, and a + /// variant classified as a carrier must also get a sample in + /// `every_controller_ref_carrying_prop_reads_the_controller_kind` below — + /// which then proves the classifier reports a CONTROLLER read for it. The + /// compiler pins the classification; the roster/`carries_controller_ref` + /// cross-check below pins that the roster does not drift the other way. + fn carries_controller_ref(prop: &FilterProp) -> bool { + match prop { + // The `ControllerRef`-carrying roster. Layer 2 can move an object + // across any scope these name (CR 613.1b), so + // `filter_prop_characteristic_reads_at` must report CONTROLLER for + // every one of them. + FilterProp::Attacking { .. } + | FilterProp::ProtectorMatches { .. } + | FilterProp::Owned { .. } + | FilterProp::HasAttachment { .. } + | FilterProp::HasAnyAttachmentOf { .. } + | FilterProp::MostPrevalentCreatureTypeIn { .. } + | FilterProp::AttackedThisTurn { .. } + | FilterProp::NameMatchesAnyPermanent { .. } => true, + // Everything else carries no `ControllerRef` of its own. Several + // still read CONTROLLER for other reasons (`Unpaired` via CR + // 702.95e, the CR 302.6 continuity props, the nested-filter + // recursers); this classifier answers only "does the variant carry + // the field", never "does it read the kind". + FilterProp::Token + | FilterProp::NonToken + | FilterProp::RepresentedByCard + | FilterProp::ControllerChoseLabel { .. } + | FilterProp::ControllerMatches { .. } + | FilterProp::WasPlayed + | FilterProp::Blocking + | FilterProp::BlockingSource + | FilterProp::CombatRelation { .. } + | FilterProp::Unblocked + | FilterProp::AttackingAlone + | FilterProp::BlockingAlone + | FilterProp::Tapped + | FilterProp::Untapped + | FilterProp::IsSaddled + | FilterProp::SaddledSource + | FilterProp::ConvokedSource + | FilterProp::HasHasteOrControlledSinceTurnBegan + | FilterProp::WithKeyword { .. } + | FilterProp::HasKeywordKind { .. } + | FilterProp::WithoutKeyword { .. } + | FilterProp::WithoutKeywordKind { .. } + | FilterProp::CanEnchant { .. } + | FilterProp::Counters { .. } + | FilterProp::Cmc { .. } + | FilterProp::ManaValueParity { .. } + | FilterProp::ManaCostIn { .. } + | FilterProp::InZone { .. } + | FilterProp::Foretold + | FilterProp::HasAdventure + | FilterProp::EnchantedBy + | FilterProp::EquippedBy + | FilterProp::AttachedToSource + | FilterProp::AttachedToRecipient + | FilterProp::Another + | FilterProp::Unpaired + | FilterProp::OtherThanTriggerObject + | FilterProp::HasColor { .. } + | FilterProp::PtComparison { .. } + | FilterProp::PowerGTSource + | FilterProp::ColorCount { .. } + | FilterProp::ManaSymbolCount { .. } + | FilterProp::HasSupertype { .. } + | FilterProp::IsChosenCreatureType + | FilterProp::IsChosenColor + | FilterProp::IsChosenCardType + | FilterProp::MatchesLastChosenCardPredicate + | FilterProp::HasSingleTarget + | FilterProp::Modal + | FilterProp::NotColor { .. } + | FilterProp::NotSupertype { .. } + | FilterProp::Suspected + | FilterProp::Renowned + | FilterProp::Goaded + | FilterProp::ToughnessGTPower + | FilterProp::PowerExceedsBase + | FilterProp::AnyOf { .. } + | FilterProp::Not { .. } + | FilterProp::InTrackedSet { .. } + | FilterProp::Modified + | FilterProp::Historic + | FilterProp::NotHistoric + | FilterProp::DifferentNameFrom { .. } + | FilterProp::DistinctFrom { .. } + | FilterProp::InAnyZone { .. } + | FilterProp::SharesQuality { .. } + | FilterProp::WasDealtDamageThisTurn + | FilterProp::DealtDamageThisTurn + | FilterProp::EnteredThisTurn + | FilterProp::ControlledContinuouslySinceTurnBegan + | FilterProp::ZoneChangedThisTurn { .. } + | FilterProp::BlockedThisTurn + | FilterProp::AttackedOrBlockedThisTurn + | FilterProp::CountersPutOnThisTurn { .. } + | FilterProp::FaceDown + | FilterProp::Transformed + | FilterProp::TargetsOnly { .. } + | FilterProp::Targets { .. } + | FilterProp::CouldBeTargetedByTriggeringSpell + | FilterProp::HasXInManaCost + | FilterProp::HasXInActivationCost + | FilterProp::WasKicked + | FilterProp::HasManaAbility + | FilterProp::HasNoAbilities + | FilterProp::Named { .. } + | FilterProp::SameName + | FilterProp::SameNameAsParentTarget + | FilterProp::IsCommander + | FilterProp::SharesCreatureTypeWithCommander + | FilterProp::Other { .. } => false, + } + } + + /// The `ControllerRef`-carrying roster read out of the `FilterProp` + /// DECLARATION, so the roster below cannot silently fall behind the enum. + /// + /// Source-scanned rather than hand-counted on purpose: a hand-maintained + /// count can only ever restate the list standing next to it, which is what + /// the previous `assert_eq!(props.len(), 8)` did. Scanning `ability.rs` + /// gives an authority the test cannot edit, so adding a variant with a + /// `ControllerRef` field turns the roster assertion RED until a sample for + /// it is added — and `carries_controller_ref` then forces the sample to be + /// classified, and the CONTROLLER assertion forces the classifier to be + /// right about it. + /// + /// CEILING: the scan is TEXTUAL, so it only sees `ControllerRef` named + /// directly in a variant's own field list. A future `NewProp { spec: + /// Box }` whose `AttackSpec` holds a `ControllerRef` stays + /// invisible here — the roster does not grow, no sample is forced, and this + /// test stays green while the classifier goes unverified for it. The + /// `!carriers.is_empty()` tripwire below only catches total scan failure + /// (declaration moved or reformatted), not an indirect carrier. Reaching + /// through a nested type needs a real type walk, which is not available + /// without a reflection dependency; classify such a variant by hand. + fn declared_controller_ref_carriers() -> Vec { + let src = include_str!("../types/ability.rs"); + let decl = "pub enum FilterProp {"; + let start = src.find(decl).expect("FilterProp declaration"); + let body = &src[start + decl.len()..]; + let body = &body[..body.find("\n}").expect("end of FilterProp")]; + + let mut carriers = Vec::new(); + let mut current: Option<&str> = None; + for line in body.lines() { + let trimmed = line.trim_start(); + // Doc comments name `ControllerRef` in prose; they declare nothing. + if trimmed.starts_with("//") { + continue; + } + // A variant header is the only thing at one indent level that opens + // with an uppercase letter; its fields sit one level deeper. + if let Some(header) = line + .strip_prefix(" ") + .filter(|l| l.starts_with(char::is_uppercase)) + { + current = Some(header.trim_end_matches([' ', '{', ',', '('])); + } + if let (true, Some(name)) = (trimmed.contains("ControllerRef"), current) { + carriers.push(name.to_string()); + } + } + carriers.sort_unstable(); + carriers.dedup(); + assert!( + !carriers.is_empty(), + "scanned zero carriers — the FilterProp declaration moved or its \ + formatting changed, so this gate is no longer scanning anything" + ); + carriers + } + + /// The variant name of a `FilterProp` sample, which is what `Debug` prints + /// first and is the only handle a value gives onto its own variant. + fn variant_name(prop: &FilterProp) -> String { + let debug = format!("{prop:?}"); + debug + .split(|c: char| !c.is_alphanumeric() && c != '_') + .next() + .expect("Debug output opens with the variant name") + .to_string() + } + + /// CR 613.1b: layer 2 can move an object across any controller scope a + /// `ControllerRef` names, so every `FilterProp` that carries one reads the + /// CONTROLLER kind. The classifier states this invariant in prose; this test + /// is what makes the next `ControllerRef`-carrying variant fail loudly if it + /// is filed under a group that omits CONTROLLER. + #[test] + fn every_controller_ref_carrying_prop_reads_the_controller_kind() { + let props = [ + FilterProp::Attacking { + defender: Some(ControllerRef::You), + }, + FilterProp::ProtectorMatches { + controller: ControllerRef::You, + }, + FilterProp::Owned { + controller: ControllerRef::You, + }, + FilterProp::HasAttachment { + kind: AttachmentKind::Aura, + controller: Some(ControllerRef::You), + exclude_source: SourceExclusion::Include, + }, + FilterProp::HasAnyAttachmentOf { + kinds: vec![AttachmentKind::Aura], + controller: Some(ControllerRef::You), + }, + FilterProp::MostPrevalentCreatureTypeIn { + zone: Zone::Library, + scope: ControllerRef::You, + }, + FilterProp::AttackedThisTurn { + defender: Some(ControllerRef::You), + }, + FilterProp::NameMatchesAnyPermanent { + controller: Some(ControllerRef::You), + }, + ]; + let mut sampled: Vec = props.iter().map(variant_name).collect(); + sampled.sort_unstable(); + sampled.dedup(); + assert_eq!( + sampled, + declared_controller_ref_carriers(), + "the sampled roster and the `ControllerRef`-carrying variants declared \ + in `types/ability.rs` have diverged — add a sample for every new \ + carrier so the CR 613.1b invariant below stays fully covered" + ); + for prop in &props { + assert!( + carries_controller_ref(prop), + "{prop:?} is in the ControllerRef roster but `carries_controller_ref` \ + classifies it as a non-carrier — one of the two is wrong" + ); + } + for prop in props { + assert!( + target_filter_characteristic_reads(&only(prop.clone())) + .contains(CharacteristicKinds::CONTROLLER), + "{prop:?} scopes its verdict by a ControllerRef, which CR 613.1b \ + lets layer 2 rewrite — it must report a CONTROLLER read" + ); + } + } + + /// CR 108.3 fixes the owner, but `Owned` is a two-operand relation and only + /// its right operand is immutable; the left operand is the source's live + /// controller (CR 109.5), which layer 2 rewrites (CR 613.1b). + #[test] + fn owned_reads_the_controller_kind_despite_an_immutable_owner() { + for controller in [ + ControllerRef::You, + ControllerRef::Opponent, + ControllerRef::ScopedPlayer, + ] { + assert!( + target_filter_characteristic_reads(&only(FilterProp::Owned { controller })) + .contains(CharacteristicKinds::CONTROLLER), + "an immutable owner operand does not make the owner-vs-controller \ + relation immutable" + ); + } + } + + /// CR 702.95e: a soulbond pair breaks when either half changes controller + /// (layer 2, CR 613.1b) or stops being a creature (layer 4, CR 613.1d). + #[test] + fn unpaired_reads_the_controller_and_card_type_kinds() { + let kinds = target_filter_characteristic_reads(&only(FilterProp::Unpaired)); + assert!( + kinds.contains(CharacteristicKinds::CONTROLLER), + "CR 702.95e: gaining control of either half breaks the pair" + ); + assert!( + kinds.contains(CharacteristicKinds::CARD_TYPES), + "CR 702.95e: either half ceasing to be a creature breaks the pair" + ); + } + + /// CR 302.6: the summoning-sickness continuity flag is re-armed for every + /// permanent whose controller changed, so both props that read it depend on + /// layer 2 (CR 613.1b). + #[test] + fn continuity_props_read_the_controller_kind() { + assert!( + target_filter_characteristic_reads(&only( + FilterProp::ControlledContinuouslySinceTurnBegan + )) + .contains(CharacteristicKinds::CONTROLLER), + "a control change re-arms the continuity flag this prop reads" + ); + + let enlist = target_filter_characteristic_reads(&only( + FilterProp::HasHasteOrControlledSinceTurnBegan, + )); + assert!( + enlist.contains(CharacteristicKinds::CONTROLLER), + "the continuity fallback of the haste-or-continuity prop is layer-2 movable" + ); + assert!( + enlist.contains(CharacteristicKinds::ABILITIES), + "CR 702.10: the haste branch is a keyword read" + ); + assert!( + enlist.contains(CharacteristicKinds::CARD_TYPES), + "CR 302.6: the creature-typeline guard is a layer-4 read" + ); + } +} diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 185747d46a..3a131a3fb0 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -11,15 +11,19 @@ use crate::game::conditions::{ eval_source_is_tapped_on_battlefield, }; use crate::game::devotion::count_devotion; -use crate::game::filter::{matches_target_filter, FilterContext}; +use crate::game::filter::{ + matches_target_filter, target_filter_characteristic_reads, + target_filter_characteristic_reads_at, CharacteristicKinds, FilterContext, + CHARACTERISTIC_READ_DEPTH, +}; use crate::game::game_object::DisplaySource; use crate::game::printed_cards::{ apply_copiable_values, ensure_keyword_triggers_for_copiable_values, intrinsic_copiable_values, is_runtime_host_lifetime_replacement, is_runtime_target_die_exile_replacement, }; use crate::game::quantity::{ - continuous_modification_dynamic_quantity, filter_uses_recipient, quantity_expr_uses_recipient, - QuantityContext, + continuous_modification_dynamic_quantity, filter_uses_recipient, + quantity_expr_characteristic_reads_at, quantity_expr_uses_recipient, QuantityContext, }; use crate::game::speed::{effective_speed, has_max_speed}; use crate::types::ability::{ @@ -1130,6 +1134,148 @@ fn static_condition_uses_object_population(condition: &StaticCondition) -> bool } } +/// CR 613.1: Which layer-writable characteristic kinds does this source-level +/// enabling condition read? +/// +/// Characteristic-dependence twin of `static_condition_uses_object_population`: +/// that predicate answers "can board MEMBERSHIP flip this gate", this one +/// answers "which layer-writable CHARACTERISTICS does the gate read". +/// +/// Two deliberate differences from the boolean twin: +/// - it RECURSES `SourceMatchesFilter` / `RecipientMatchesFilter` (both are +/// `false` there because a battlefield entry cannot change whether the source +/// or the recipient matches — but a layer write to that object very much can); +/// - `Unrecognized` maps to ALL, not to a kind. +/// +/// EXHAUSTIVE and wildcard-free. Conditions over zones, turn structure, player +/// totals, statuses (CR 110.5a) and counters (CR 122.1) read NO layer-writable +/// characteristic and MUST classify EMPTY — a blanket-ALL here would saturate +/// the read union on ordinary boards and turn the entry-flush gate into an +/// unconditional full re-evaluation. +fn static_condition_characteristic_reads(condition: &StaticCondition) -> CharacteristicKinds { + static_condition_characteristic_reads_at(condition, CHARACTERISTIC_READ_DEPTH) +} + +fn static_condition_characteristic_reads_at( + condition: &StaticCondition, + depth: u32, +) -> CharacteristicKinds { + let Some(depth) = depth.checked_sub(1) else { + return CharacteristicKinds::ALL; + }; + match condition { + // Threshold gates: both operands are magnitudes. + StaticCondition::QuantityComparison { lhs, rhs, .. } => { + quantity_expr_characteristic_reads_at(lhs, depth) + .union(quantity_expr_characteristic_reads_at(rhs, depth)) + } + // CR 700.5: devotion sums mana symbols in the mana costs of the + // permanents the source's controller controls. + StaticCondition::DevotionGE { .. } => { + CharacteristicKinds::MANA_COST.union(CharacteristicKinds::CONTROLLER) + } + // CR 105.1: color histogram over every battlefield permanent. + StaticCondition::SharesColorWithMostCommonColorAmongPermanents => { + CharacteristicKinds::COLOR + } + // CR 109.4: "you control [filter]" — controller-scoped membership over a + // live filter. A filterless `IsPresent` still reads the controller scope. + StaticCondition::IsPresent { filter } => { + filter + .as_ref() + .map_or(CharacteristicKinds::CONTROLLER, |f| { + CharacteristicKinds::CONTROLLER + .union(target_filter_characteristic_reads_at(f, depth)) + }) + } + // CR 509.1a + CR 109.4: per-player board census over a live filter. + StaticCondition::DefendingPlayerControls { filter } => CharacteristicKinds::CONTROLLER + .union(target_filter_characteristic_reads_at(filter, depth)), + // CR 903.3 + CR 613.1b: commander designation is fixed at deck + // construction; only the control scope is layer-writable. + StaticCondition::ControlsCommander { .. } + | StaticCondition::SourceControllerEquals { .. } => CharacteristicKinds::CONTROLLER, + // Recurse combinators, early-exiting once saturated. + StaticCondition::And { conditions } | StaticCondition::Or { conditions } => conditions + .iter() + .fold(CharacteristicKinds::EMPTY, |acc, c| { + if acc.is_all() { + acc + } else { + acc.union(static_condition_characteristic_reads_at(c, depth)) + } + }), + StaticCondition::Not { condition } => { + static_condition_characteristic_reads_at(condition, depth) + } + // Parse fallback: text unknown, so every kind is conservatively assumed. + StaticCondition::Unrecognized { .. } => CharacteristicKinds::ALL, + // NET-NEW relative to the boolean twin: these match a live object + // against a filter, so every characteristic that filter reads is a live + // read of this condition. + StaticCondition::SourceMatchesFilter { filter } + | StaticCondition::RecipientMatchesFilter { filter } => { + target_filter_characteristic_reads_at(filter, depth) + } + // CR 301.5 + CR 303.4 + CR 306.1: attachment relationships are decided by + // the attachment's subtype (Equipment / Aura) and by the attached + // object's card type. + StaticCondition::SourceIsEquipped + | StaticCondition::SourceIsEnchanted + | StaticCondition::SourceAttachedToCreature => CharacteristicKinds::CARD_TYPES, + // Reads no layer-writable characteristic: zone contents, turn structure, + // combat state, player-scoped totals and designations, statuses + // (CR 110.5a), counters (CR 122.1), per-object chosen attributes, and + // cast history. Enumerated explicitly (no wildcard). + // + // CR 401.1: `TopOfLibraryMatches` reads the controller's LIBRARY top; no + // write to a battlefield object can change that card's characteristics. + StaticCondition::TopOfLibraryMatches { .. } + | StaticCondition::ChosenColorIs { .. } + | StaticCondition::ChosenLabelIs { .. } + | StaticCondition::HasMaxSpeed + | StaticCondition::SpeedGE { .. } + | StaticCondition::DayNightIs { .. } + | StaticCondition::HasCounters { .. } + | StaticCondition::CastVariantPaid { .. } + | StaticCondition::RecipientHasCounters { .. } + | StaticCondition::ClassLevelGE { .. } + | StaticCondition::SourceAttackingAlone + | StaticCondition::SourceIsAttacking + | StaticCondition::RecipientAttackingOwnerTarget { .. } + | StaticCondition::SourceIsBlocking + | StaticCondition::SourceIsBlocked + | StaticCondition::IsMonarch + | StaticCondition::IsInitiative + | StaticCondition::NoMonarch + | StaticCondition::HasCityBlessing + | StaticCondition::CompletedADungeon + | StaticCondition::WasStartingPlayer { .. } + | StaticCondition::SpellCastWithVariantThisTurn { .. } + | StaticCondition::OpponentPoisonAtLeast { .. } + | StaticCondition::UnlessPay { .. } + | StaticCondition::DuringYourTurn + | StaticCondition::DuringOpponentsTurn + | StaticCondition::SourceEnteredThisTurn + | StaticCondition::SourceHasDealtDamage + | StaticCondition::WasCast { .. } + | StaticCondition::IsRingBearer + | StaticCondition::RingLevelAtLeast { .. } + | StaticCondition::SourceIsTapped + | StaticCondition::IsTapped { .. } + | StaticCondition::SourceIsSaddled + | StaticCondition::SourceIsMonstrous + | StaticCondition::SourceIsHarnessed + | StaticCondition::SourceIsPaired + | StaticCondition::SourceInZone { .. } + | StaticCondition::EnchantedIsFaceDown + | StaticCondition::SourceIsFaceUp + | StaticCondition::AdditionalCostPaid + | StaticCondition::CastingAsVariant { .. } + | StaticCondition::None => CharacteristicKinds::EMPTY, + } +} + /// CR 611.3a: ENTRY-AWARE narrowing for a population-sensitive source-level /// enabling CONDITION. `static_condition_uses_object_population` proves a /// condition *can* gate on board population; this proves a SPECIFIC entering @@ -1834,8 +1980,9 @@ fn rebuild_static_index_at_top() -> bool { /// stops re-deriving them, and a naturally-menace creature keeps its printed /// menace when it stops being suspected. /// -/// Called from the Step-1 reset of both the full (`evaluate_layers`) and -/// incremental (`apply_layers_incremental`) passes, immediately after the live +/// Called from `reset_recipient_to_base`, which both passes go through — the +/// full one (`evaluate_layers`) board-wide, the incremental one +/// (`prepare_incremental_flush`) over recipients — immediately after the live /// fields are reset to base, so the derived grant rides along with every reset. fn derive_suspected_abilities(obj: &mut crate::game::game_object::GameObject) { if !obj.is_suspected { @@ -1862,11 +2009,12 @@ fn derive_suspected_abilities(obj: &mut crate::game::game_object::GameObject) { /// CR 613.1 + CR 707.2 + CR 708.2: seed live characteristics from base_*; shared /// by Step-1 top-of-pass reset and the CR 613.2b Layer-1b face-down re-seed. /// -/// Assigns the live copiable-characteristic fields from their `base_*` baseline, -/// exactly mirroring the Step-1 reset block field-for-field. Does NOT call -/// `sync_missing_base_characteristics`, and does NOT touch controller, the -/// combat-assignment flags, or `derive_suspected_abilities` — those stay inline -/// in Step 1 (they are not part of the face-down CR 708.2a re-seed). +/// Assigns the live copiable-characteristic fields from their `base_*` baseline. +/// Does NOT call `sync_missing_base_characteristics`, and does NOT touch +/// controller, the combat-assignment flags, or `derive_suspected_abilities` — +/// those live in `reset_recipient_to_base`, which calls this as its second step +/// (they are not part of the face-down CR 708.2a re-seed, which is why this is +/// separable at all). fn seed_live_characteristics_from_base(obj: &mut crate::game::game_object::GameObject) { obj.name = obj.base_name.clone(); obj.power = obj.base_power; @@ -2039,23 +2187,12 @@ pub fn evaluate_layers(state: &mut GameState) { let mut face_down_ids: Vec = Vec::new(); for &id in &bf_ids { if let Some(obj) = state.objects.get_mut(&id) { - obj.sync_missing_base_characteristics(); - seed_live_characteristics_from_base(obj); + reset_recipient_to_base(obj); + // Reset does not touch `face_down`, so this reads the same value + // whichever side of the reset it sits on. if obj.face_down { face_down_ids.push(id); } - // CR 613.1b: Reset controller to the object's base controller; - // Layer 2 re-applies continuous control-changing effects. - obj.controller = obj.base_controller.unwrap_or(obj.owner); - // CR 613.11 + CR 510.1a: Reset combat-assignment rule flags; - // re-applied after object-characteristic layers are complete. - obj.assigns_damage_from_toughness = false; - obj.assigns_damage_as_though_unblocked = false; - obj.assigns_no_combat_damage = false; - // CR 701.60c: re-derive the suspected designation's menace + - // "can't block" onto the just-reset live fields (not base), so - // the grant lasts exactly as long as the designation. - derive_suspected_abilities(obj); } } // CR 702.94a + CR 400.3: Hand-zone continuous effects (Lorehold-style @@ -3429,6 +3566,27 @@ pub fn flush_layers(state: &mut GameState) { } } +/// CR 613.1: Single authority for the per-object "back to base" reset. Both +/// arms go through here — the full pass applies it board-wide over the +/// phased-in battlefield, the incremental arm applies it to recipients only — +/// so the two arms cannot drift in what "base" means. +fn reset_recipient_to_base(obj: &mut crate::game::game_object::GameObject) { + obj.sync_missing_base_characteristics(); + seed_live_characteristics_from_base(obj); + // CR 613.1b: layer 2 control-change effects are re-applied from the base + // controller, not accumulated on top of the previous pass's result. + obj.controller = obj.base_controller.unwrap_or(obj.owner); + // CR 613.11 + CR 510.1a: combat-assignment rule effects are re-derived every + // pass, so the previous pass's grants must not persist through the reset. + obj.assigns_damage_from_toughness = false; + obj.assigns_damage_as_though_unblocked = false; + obj.assigns_no_combat_damage = false; + // CR 701.60c: re-derive the suspected designation's menace + "can't block" + // onto the just-reset live fields (not base), so the grant lasts exactly as + // long as the designation. + derive_suspected_abilities(obj); +} + fn prepare_incremental_flush( state: &mut GameState, entered_ids: &BTreeSet, @@ -3448,13 +3606,7 @@ fn prepare_incremental_flush( // and collecting the prepared effect set so local CDAs are visible again. for &id in &recipient_ids { if let Some(obj) = state.objects.get_mut(&id) { - obj.sync_missing_base_characteristics(); - seed_live_characteristics_from_base(obj); - obj.controller = obj.base_controller.unwrap_or(obj.owner); - obj.assigns_damage_from_toughness = false; - obj.assigns_damage_as_though_unblocked = false; - obj.assigns_no_combat_damage = false; - derive_suspected_abilities(obj); + reset_recipient_to_base(obj); } } crate::types::game_state::StaticSourceIndex::rebuild_from_state(state); @@ -3467,6 +3619,11 @@ fn prepare_incremental_flush( return None; } if active_effects_force_incremental_escalation(state, entered_ids, &active_effects) + || population_probe_blinded_by_entrant_characteristic_change( + state, + entered_ids, + &active_effects, + ) || any_active_static_condition_perturbed_by_entry(state, entered_ids) { return None; @@ -3481,72 +3638,595 @@ fn prepare_incremental_flush( /// Decide whether an `EnteredObjects` flush must conservatively escalate to a /// full re-evaluation. /// -/// Two axes, both required-clean for the fast path: +/// SINGLE AUTHORITY: this delegates to `prepare_incremental_flush` — the exact +/// gate `flush_layers` consults — rather than re-deriving the decision. It +/// previously carried its own two-axis reimplementation, which had already +/// drifted from production in two ways: it omitted the recipient-sourced-effect +/// check, and it evaluated the active-effect axis against a board on which the +/// recipient reset had NOT yet run (production resets first, so a stale layer-6 +/// removal could hide a recipient's live static from the test predicate but not +/// from production). A test predicate that answers a different question than the +/// production gate cannot certify the production gate. /// -/// 1. Per-entered preconditions: the entered object must not itself be the -/// source of a continuous effect, carry a CDA static, or carry a -/// control-override / type-change / text-change / counter / attachment / -/// transient effect (the entry enqueued none for a plain token). +/// The escalation disjuncts are enumerated at the `prepare_incremental_flush` +/// call site and documented on each disjunct's own function. This wrapper +/// deliberately restates neither: a second enumeration here is exactly what +/// drifted last time. /// -/// 2. Board-wide escalation: no ACTIVE continuous effect may have a magnitude, -/// affected set, or source-level enabling CONDITION that reads battlefield -/// object population. -/// CR 611.3a: a static-ability continuous effect isn't locked in; it applies -/// at any moment to whatever its text indicates — so a board-population- -/// dependent magnitude, affected set, or enabling condition re-evaluates when -/// an object enters, changing PRE-EXISTING recipients. CR 613.7d: the entering -/// object receives its timestamp on zone entry. CR 613.8a: dependency/timestamp -/// ordering operates on the live set. This scan is O(active-effect-count), NOT -/// O(battlefield). +/// CR 611.3a: a static-ability continuous effect isn't locked in; it applies +/// at any moment to whatever its text indicates — so a board-population- +/// dependent magnitude, affected set, or enabling condition re-evaluates when +/// an object enters, changing PRE-EXISTING recipients. CR 613.7d: the entering +/// object receives its timestamp on zone entry. CR 613.8a: dependency/timestamp +/// ordering operates on the live set. +/// +/// Takes `&GameState` and works on a clone because `prepare_incremental_flush` +/// mutates: after its per-entrant precondition scan it resets every recipient +/// to base. The caller asks a question and must not have its board +/// half-flushed by the asking. #[cfg(test)] pub(crate) fn incremental_flush_must_escalate( state: &GameState, entered_ids: &BTreeSet, ) -> bool { - // Axis 1 — per-entered preconditions. - for &id in entered_ids { - let Some(obj) = state.objects.get(&id) else { - // The entered object already left (e.g. it was a token that died to - // an SBA before flush). A full pass is the safe handling. - return true; - }; - if entered_object_blocks_incremental(state, obj) { - return true; + let mut scratch = state.clone(); + prepare_incremental_flush(&mut scratch, entered_ids).is_none() +} + +/// The two population-read channels of a single effect, computed once: +/// `(dynamic-magnitude sensitivity, affected-set sensitivity)`. Axis 2a +/// (`active_effects_force_incremental_escalation`) needs the split to pick which +/// per-entrant narrowing applies without walking the affected filter twice. +/// +/// MEMBERSHIP question, not a characteristic question — see +/// [`CharacteristicKinds`] for the deliberate split. +fn effect_population_reads(e: &ActiveContinuousEffect) -> (bool, bool) { + ( + continuous_modification_dynamic_quantity(&e.modification) + .is_some_and(crate::game::quantity::quantity_expr_uses_object_count), + crate::game::filter::affected_filter_uses_object_population(&e.affected_filter), + ) +} + +/// The live read union, split by whether a contribution is attributable to one +/// continuous effect. +struct LiveCharacteristicReads { + /// Read kinds that belong to no single effect, so no writer is ever exempt + /// from them: dynamic magnitudes and Continuous statics' enabling + /// conditions. + global: CharacteristicKinds, + /// `global` plus every live affected filter's kinds — the full ReadKinds + /// union that drives the entrant-independent global exit. + total: CharacteristicKinds, +} + +/// CR 613.1: the union of layer-writable characteristic kinds that the CURRENT +/// board actually READS, per the live read channels. +/// +/// Entrant-independent, so the entry-flush gate computes it once per flush: +/// +/// ```text +/// global := ⋃ dynamic-magnitude kinds // every live modification's magnitude +/// ∪ ⋃ live condition kinds // every live effect's retained condition +/// ∪ ⋃ Continuous condition kinds // every live Continuous static's condition +/// ∪ ⋃ transient gate kinds // every INSTALLED transient's duration + condition +/// ReadKinds := global ∪ ⋃ affected-filter kinds // every live modification's affected filter +/// ``` +/// +/// All five channels are unioned UNCONDITIONALLY. An earlier design gated the +/// affected-filter channel on the write set already intersecting, which is +/// unsound: a board whose only name-sensitive read lives in another static's +/// affected filter has an empty base union, so the gate would never notice a +/// layer-3 name rewrite reaching the entrant. +/// +/// The affected-filter channel is reported separately because it is the only +/// one attributable to a single effect, and CR 613.6 puts an effect's own +/// affected filter out of reach of its own writes — see +/// [`AffectedFilterReadTally`]. The other four channels are board-level and +/// admit no such exclusion. +/// +/// WHAT CONVERGES WHERE. The `e.condition` channel below is NOT a single +/// authority over every gate on the board, and must not be documented as one. +/// The full producer census of `ActiveContinuousEffect::condition`, and which +/// channel can see each one. One row per construction site — the eight +/// `ActiveContinuousEffect { .. }` literals in the engine, six here and two in +/// `stickers.rs`, all reached through +/// [`collect_shared_active_continuous_effects`]: +/// +/// | producer | `condition` it writes | seen by | +/// |---|---|---| +/// | The Ring emblem (CR 701.54c) | `None` | nothing to see | +/// | [`active_continuous_effects_from_static_definitions`] (printed statics) | `def.condition` | `e.condition` AND the source walk | +/// | [`expand_granted_static_effects`] | `inner.condition` | `e.condition` ONLY | +/// | `expand_granted_activated_abilities` | `None` | nothing to see | +/// | `expand_granted_triggered_abilities` | `None` | nothing to see | +/// | [`gather_transient_continuous_effects`] | recipient-context `tce.condition` only | `e.condition`, plus the transient walk for what it drops | +/// | `stickers.rs` (two P/T sites) | `None` | nothing to see | +/// +/// One alternate ENTRY into row 2 is out of this census by scope: +/// [`active_continuous_effects_from_base_static_source`] re-enters +/// [`active_continuous_effects_from_static_definitions`] for off-zone keyword +/// queries (sole caller `off_zone_characteristics.rs`) and never feeds +/// [`evaluate_layers`], so no flush channel needs to see its conditions. +/// +/// Two consequences a reader must not get backwards: +/// +/// * a GRANTED-INNER static's condition lives in `inner.condition`, nested in +/// `ContinuousModification::GrantStaticAbility`. The source walk below reads +/// `obj.static_definitions.iter_all()`, i.e. OUTER definitions, so it sees +/// that condition only once a previous pass has materialized the granted +/// definition onto the recipient. On a never-yet-evaluated state — hand-built +/// or freshly deserialized — `e.condition` is the ONLY channel that sees it. +/// That is what the `e.condition` union is load-bearing for; it is not +/// redundant with the source walk. +/// * a TRANSIENT reaches `e.condition` only when its gate is currently ON *and* +/// the condition is recipient-context, because +/// [`gather_transient_continuous_effects`] skips a transient that is not live +/// and strips a source-level condition from the effect it pushes. Both +/// discarded shapes are exactly the ones an entry can flip, hence the +/// separate walk over `state.transient_continuous_effects`. +/// +/// `active_combat_assignment_rule_effects_from_static_definitions` / +/// `collect_transient_combat_assignment_rule_effects` duplicate the same +/// retain/strip logic for `ActiveCombatAssignmentRuleEffect`; those effects +/// change CR 613.11 rules, not characteristics, so they are outside this union +/// — but a future condition channel added here needs patching there too. +/// +/// Walks early-exit the moment the union saturates to +/// [`CharacteristicKinds::ALL`]. +fn live_characteristic_reads( + state: &GameState, + active_effects: &[ActiveContinuousEffect], +) -> LiveCharacteristicReads { + let mut global = CharacteristicKinds::EMPTY; + let mut affected = CharacteristicKinds::EMPTY; + for e in active_effects { + if global.union(affected).is_all() { + break; + } + if let Some(q) = continuous_modification_dynamic_quantity(&e.modification) { + global = global.union(quantity_expr_characteristic_reads_at( + q, + CHARACTERISTIC_READ_DEPTH, + )); } + affected = affected.union(target_filter_characteristic_reads(&e.affected_filter)); + // CR 611.2c + CR 611.3a: CR 611.2c locks in the affected SET of a + // resolution-created continuous effect, but nothing locks in its + // enabling condition — a retained recipient-context condition is + // re-evaluated per recipient on every pass, exactly like the + // "isn't locked in" static-ability effect of CR 611.3a. The condition + // gates WHETHER the effect applies at all, so it is a board-level read + // and belongs in `global`, NOT in the per-effect `affected` channel + // that CR 613.6 lets an effect exclude from its own writes. Covers the + // gathered producers only — see the walks below for what this channel + // structurally cannot see. + if let Some(condition) = e.condition.as_ref() { + global = global.union(static_condition_characteristic_reads(condition)); + } + } + // CR 611.2b + CR 611.2c: both gates of a resolution-created effect — its + // "for as long as" duration and its retained enabling condition — decide + // WHETHER the effect applies at all, so both are board-level reads. + // + // Walked straight off `state.transient_continuous_effects` rather than off + // `active_effects`, because neither shape that matters survives that + // projection: `gather_transient_continuous_effects` skips a transient whose + // gate is currently OFF (an OFF gate is exactly the one an entry can turn + // ON), and it strips a source-level condition from the effect it pushes, + // leaving `e.condition == None` above. + if !global.union(affected).is_all() { + for tce in &state.transient_continuous_effects { + if global.union(affected).is_all() { + break; + } + for condition in transient_gate_conditions(tce) { + global = global.union(static_condition_characteristic_reads(condition)); + } + } + } + if !global.union(affected).is_all() { + // CR 611.3a: a Continuous static's enabling condition re-evaluates as the + // board changes, so it is a live read channel in its own right. + let affected_so_far = affected; + for_each_static_effect_source(state, |_state, obj| { + if global.union(affected_so_far).is_all() { + return; + } + for def in obj.static_definitions.iter_all() { + if def.mode != StaticMode::Continuous { + continue; + } + if let Some(condition) = def.condition.as_ref() { + global = global.union(static_condition_characteristic_reads(condition)); + } + } + }); } + LiveCharacteristicReads { + global, + total: global.union(affected), + } +} - // Axis 2a — magnitude + affected-set over the EXISTING active effect set, - // NARROWED to entries that actually perturb the population input. - // - // Two-stage test per effect: the committed exhaustive classifier - // (`quantity_expr_uses_object_count` / `affected_filter_uses_object_population`) - // is the OUTER conjunct (compile-time tripwire — a future population-reading - // variant forces a classification). Then the entry-aware narrowing layer asks - // whether any ENTERED object can flip THIS effect's population input. - // - // CR 109.5: the filter's "you control" must resolve against the EFFECT - // SOURCE's controller, not the entered object's — so `ctx` is built per-effect - // from `e.source_id` + `e.controller`. Escalation is `classifier(e) && - // any_entered_perturbs(e)`; both required. - let active_effects = collect_shared_active_continuous_effects(state); - if active_effects_force_incremental_escalation(state, entered_ids, &active_effects) { - return true; +/// CR 613.6: a continuous effect whose modifications span several layers uses +/// ONE affected-object set. It is determined the first time the effect applies +/// and then retained for every other applicable layer — `started_effect_sets` +/// in [`apply_continuous_effect_filtered`] is exactly that retention, keyed by +/// [`ContinuousEffectGroupKey`]. The set is therefore fixed BEFORE any of that +/// effect's own modifications run, in the full pass and in the incremental pass +/// alike, so a modification can never move the affected set of the effect it +/// belongs to — not for the entrant and not for a pre-existing object. +/// +/// That makes the affected-filter read channel self-exclusive. When the gate +/// asks whether writer `M` can invalidate a live read, `M`'s own effect's +/// affected filter is not a read `M` can move; only OTHER effects' affected +/// filters are. `name_rewrite_entry_escalates_through_affected_filter_reads` +/// pins the cross-effect direction, where the rename is definition 0 and the +/// name-sensitive buff is definition 1, so the buff's set is determined AFTER +/// the rename applied. `incremental_entry_retains_multi_layer_effect_affected_set` +/// pins the self direction: one definition whose layer-4 `AddType` write is +/// read only by its own `Non(Creature)` affected filter, which CR 613.6 has +/// already locked in. +/// +/// Attribution is per retention group, never per modification: the sibling +/// modifications of one definition each carry a clone of the same affected +/// filter, so excluding one modification's copy would leave its siblings' +/// identical copies contributing the same kinds. +/// +/// `duplicated` keeps the exclusion O(effects) instead of O(effects²): a kind +/// read by two or more distinct groups survives excluding any single group. +struct AffectedFilterReadTally { + by_group: HashMap, + /// Kinds read by at least one attributed group's affected filter. + attributed: CharacteristicKinds, + /// Kinds read by at least two DISTINCT attributed groups. + duplicated: CharacteristicKinds, + /// Affected-filter reads from effects with no retention identity. Fail + /// closed — these are never excluded. + unattributed: CharacteristicKinds, +} + +impl AffectedFilterReadTally { + /// The affected-filter reads that survive excluding `effect`'s own CR 613.6 + /// retention group. + fn excluding_own_group( + &self, + state: &GameState, + effect: &ActiveContinuousEffect, + ) -> CharacteristicKinds { + let own = continuous_effect_group_key(state, effect) + .and_then(|key| self.by_group.get(&key).copied()) + .unwrap_or(CharacteristicKinds::EMPTY); + self.unattributed + .union(self.duplicated) + .union(self.attributed.without(own)) } +} - // Axis 2b — source-level enabling CONDITION over the EXISTING static-ability - // sources, NARROWED to entries that actually perturb the condition. Conditions - // remain attached to collected effects for application-time evaluation, while - // this source walk supplies the before/after truth comparison required for an - // incremental full-rebuild decision. - // - // CR 611.3a + CR 611.3b: when such a source-level enabling condition depends - // on board population, an object entering can flip the condition for the - // WHOLE recipient set, changing PRE-EXISTING recipients — so escalate to a - // full rebuild. The entry-aware narrowing (built per-source from the visited - // object, CR 109.5) skips escalation when no entered object can perturb the - // gate; the truth-delta refinement (below) skips escalation even when an - // entry perturbs the gate INPUT but does not flip its truth value. - any_active_static_condition_perturbed_by_entry(state, entered_ids) +/// Attribute every live affected filter's characteristic reads to its CR 613.6 +/// retention group. Deliberately built only after the global disjointness exit +/// has already failed, so the boards that leave the gate at that exit never pay +/// for the grouping. +fn tally_affected_filter_reads( + state: &GameState, + active_effects: &[ActiveContinuousEffect], +) -> AffectedFilterReadTally { + let mut by_group: HashMap = HashMap::new(); + let mut unattributed = CharacteristicKinds::EMPTY; + for e in active_effects { + let reads = target_filter_characteristic_reads(&e.affected_filter); + if reads.is_empty() { + continue; + } + match continuous_effect_group_key(state, e) { + Some(key) => { + let slot = by_group.entry(key).or_insert(CharacteristicKinds::EMPTY); + *slot = slot.union(reads); + } + None => unattributed = unattributed.union(reads), + } + } + // Order-independent: `duplicated` ends up as the kinds present in two or + // more group entries however the map iterates. + let mut attributed = CharacteristicKinds::EMPTY; + let mut duplicated = CharacteristicKinds::EMPTY; + for reads in by_group.values() { + duplicated = duplicated.union(attributed.intersection(*reads)); + attributed = attributed.union(*reads); + } + AffectedFilterReadTally { + by_group, + attributed, + duplicated, + unattributed, + } +} + +/// CR 613.1 + CR 613.1d + CR 613.4a: escalate when the population probes below +/// are asking about an object whose characteristics the pass is about to change. +/// Layers apply in order (CR 613.1), so a layer-4 type rewrite (CR 613.1d) has +/// already happened by the time any later layer's population read — including a +/// layer-7a CDA's P/T definition (CR 613.4a) — is evaluated; the gate's probes +/// run BEFORE any layer. +/// +/// `active_effects_force_incremental_escalation` asks "does the ENTERING object +/// join this counted population?" against the entrant's characteristics as they +/// stand at gate time — which is BEFORE any layer has applied to it. If some +/// other active effect will change those characteristics later in the same pass, +/// the probe answered about the wrong object. The same blindness afflicts the +/// per-entrant probe in `any_active_static_condition_perturbed_by_entry`: a +/// static's enabling condition (CR 611.3a) that counts a population also reads +/// the entrant pre-layer. +/// +/// Found by differential verification against a full re-evaluation during +/// development, on Ashaya, Soul of the Wild: its CDA counts "lands you control" +/// (layer 7a) while its own second static makes nontoken creatures Forest LANDS +/// (layer 4). An entering Grizzly Bears is not a land at gate time, so the probe +/// reported no perturbation, the incremental arm ran, and Ashaya kept a stale +/// 1/1 where a full pass derives 2/2. Condition-channel twin of the same +/// blindness: Life and Limb (layer 4: all Saprolings are Forest lands) plus +/// Sylvan Advocate ("as long as you control six or more lands...") — an +/// entering Saproling flips the Advocate's condition only post-layer. The +/// discriminating fixture for that channel is the synthetic +/// `condition_gated_anthem_entry_escalates_when_entrant_types_rewritten`, which +/// is constructed to take the incremental path; the printed Life and Limb pair +/// is pinned end-to-end separately in +/// `tests/integration/life_and_limb_sylvan_advocate.rs`. +/// +/// Closed by a typed READ/WRITE-KIND RELATION rather than by a one-sided list +/// of "population-keying" writers. Both sides are classified over the same +/// lattice ([`CharacteristicKinds`], one bit per layer-writable characteristic +/// kind), and the gate fires only when they INTERSECT: +/// +/// ```text +/// escalate ⇔ ∃ live M reaching an entrant with writes(M) ∩ reads_M_can_move ≠ ∅ +/// +/// reads_M_can_move := global reads (magnitudes, conditions) +/// ∪ affected-filter reads of every effect EXCEPT M's own +/// ``` +/// +/// The exclusion is CR 613.6, not an optimisation: M's own effect's affected +/// set is retained from the moment that effect first applies, so it is already +/// fixed before M runs and M cannot move it. See [`AffectedFilterReadTally`]. +/// +/// `writes(M)` comes from [`modification_characteristic_writes`], `ReadKinds` +/// from [`live_characteristic_reads`]. The predecessor of this gate recognized +/// only three write kinds (card types, controller, color) and did not classify +/// the read side at all, so it was simultaneously too narrow (a layer-3 name +/// rewrite or a layer-6 keyword grant reaching the entrant was invisible to a +/// name- or keyword-keyed population) and too wide (any recognized writer +/// escalated even against a board that reads no kind it writes). +/// +/// SOUNDNESS: +/// +/// 1. If no live modification matches an entrant pre-layer, nothing applies to +/// it, so post-layer characteristics equal pre-layer ones and the pre-layer +/// population probe is exact. Chains cannot start without a pre-layer match. +/// 2. If M reaches the entrant and writes kind K, staleness requires some live +/// read to depend on K — through a counted magnitude, through a static's +/// enabling condition, or through another modification's affected filter. +/// `ReadKinds` unions all three unconditionally, so no live read of K can lie +/// outside it; kinds disjoint from every live read cannot flip any verdict. +/// The only read subtracted per modification is M's own effect's affected +/// filter, and CR 613.6 proves that one is not a read M can move: the +/// effect's affected set is determined when the effect first applies and +/// retained for its later layers, so it is fixed strictly before any of that +/// effect's own modifications run. Every other effect's affected filter stays +/// in, which is what the layer-3 rename channel rides on. +/// 3. Every uncertain form on either side maps to [`CharacteristicKinds::ALL`] +/// (unparsed conditions, arbitrary player predicates, recursion overflow, +/// `RemoveAllAbilities` stripping CDAs), so classification error can only +/// OVER-escalate: a full re-evaluation is slower, never wrong. +/// 4. STATED BOUNDARY, inherited unchanged. The reach probe below evaluates +/// affected filters against the previous FINAL state, while full evaluation +/// matches them at intermediate layer states; a count-thresholded affected +/// filter can therefore diverge from the probe in either direction. This +/// relation neither narrows nor widens that pre-existing limitation — it is a +/// property of the probe, not of the kind typing. Points 1-3 are a soundness +/// argument for the KIND RELATION, not a proof that the probe itself is +/// exact. +/// +/// Both classifiers are EXHAUSTIVE and wildcard-free, which is what actually +/// holds the line: a future `ContinuousModification` or `FilterProp` cannot be +/// added without deciding which kinds it writes and reads. +/// +/// Evaluation order is cheapest-first, and each stage can return "no escalation" +/// on its own: +/// +/// 1. `all_writes` — pure enum matches over the live modifications, no filter +/// work at all. +/// 2. `ReadKinds` — entrant-independent, computed once per flush, early-exiting +/// at ALL. +/// 3. their intersection — if empty, ZERO `matches_target_filter` calls happen. +/// This is the exit taken by the pinned fast paths, whose anthems write +/// `{PowerToughness}` while their boards read `{CardTypes, Controller}` or +/// `{ManaCost, Controller, CardTypes}`. +/// 4. only then, the CR 613.6 group attribution and the per-entrant +/// affected-filter reach probe, and only for the modifications that survived +/// stage 3. +fn population_probe_blinded_by_entrant_characteristic_change( + state: &GameState, + entered_ids: &BTreeSet, + active_effects: &[ActiveContinuousEffect], +) -> bool { + // Stage 1: pure enum matches. + let mut all_writes = CharacteristicKinds::EMPTY; + for e in active_effects { + all_writes = all_writes.union(modification_characteristic_writes(&e.modification)); + if all_writes.is_all() { + break; + } + } + if all_writes.is_empty() { + return false; + } + // Stage 2: entrant-independent read union, computed once. + let read_kinds = live_characteristic_reads(state, active_effects); + // Stage 3: global disjointness — no per-entrant filter matching at all. + if !all_writes.intersects(read_kinds.total) { + return false; + } + // Stage 3.5: only boards that survive stage 3 pay for attributing the + // affected-filter reads to their CR 613.6 retention groups. + let affected_reads = tally_affected_filter_reads(state, active_effects); + // Stage 4: per-entrant reach probe, restricted to modifications whose own + // write set intersects a live read that modification could actually move. + active_effects.iter().any(|e| { + let reads_e_can_move = read_kinds + .global + .union(affected_reads.excluding_own_group(state, e)); + if !modification_characteristic_writes(&e.modification).intersects(reads_e_can_move) { + return false; + } + let ctx = FilterContext::from_source_with_controller(e.source_id, e.controller); + entered_ids + .iter() + .any(|id| matches_target_filter(state, *id, &e.affected_filter, &ctx)) + }) +} + +/// CR 613.1: which layer-writable characteristic kinds a modification WRITES. +/// +/// EXHAUSTIVE and wildcard-free over `ContinuousModification`, the write half of +/// the relation documented on +/// [`population_probe_blinded_by_entrant_characteristic_change`]. A variant that +/// writes several kinds returns their union; a variant whose effect cannot be +/// bounded returns [`CharacteristicKinds::ALL`]. +fn modification_characteristic_writes(m: &ContinuousModification) -> CharacteristicKinds { + modification_characteristic_writes_at(m, GRANTED_STATIC_WRITE_DEPTH) +} + +/// Depth bound for `GrantStaticAbility` recursion: a granted static may itself +/// grant a static, and the granted `StaticDefinition` is owned data with no +/// structural bound, so the walk is capped and falls back to +/// [`CharacteristicKinds::ALL`] — conservative, per soundness point 3. +const GRANTED_STATIC_WRITE_DEPTH: u32 = 4; + +fn modification_characteristic_writes_at( + m: &ContinuousModification, + depth: u32, +) -> CharacteristicKinds { + let Some(depth) = depth.checked_sub(1) else { + return CharacteristicKinds::ALL; + }; + match m { + // ---- CR 613.1d (layer 4): typeline. ---- + ContinuousModification::AddType { .. } + | ContinuousModification::RemoveType { .. } + | ContinuousModification::SetCardTypes { .. } + | ContinuousModification::AddSubtype { .. } + | ContinuousModification::RemoveSubtype { .. } + | ContinuousModification::RemoveAllSubtypes { .. } + | ContinuousModification::AddAllCreatureTypes + | ContinuousModification::AddAllBasicLandTypes + | ContinuousModification::AddAllLandTypes + | ContinuousModification::AddChosenSubtype { .. } + | ContinuousModification::AddSupertype { .. } + | ContinuousModification::RemoveSupertype { .. } => CharacteristicKinds::CARD_TYPES, + // CR 305.7: setting a basic land type replaces the land's subtypes AND + // removes its abilities (the Song of the Dryads / Blood Moon class), so + // it is a genuine two-kind writer. + ContinuousModification::SetBasicLandType { .. } + | ContinuousModification::SetChosenBasicLandType => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::ABILITIES) + } + + // ---- CR 613.1c (layer 3) + CR 612.8: name. ---- + ContinuousModification::SetName { .. } + | ContinuousModification::SetTextName { .. } + | ContinuousModification::SetChosenName => CharacteristicKinds::NAME_TEXT, + + // ---- CR 613.1e (layer 5): color. ---- + ContinuousModification::SetColor { .. } + | ContinuousModification::AddColor { .. } + | ContinuousModification::AddChosenColor { .. } => CharacteristicKinds::COLOR, + + // ---- CR 613.1b (layer 2): control. ---- + // CR 109.3: control is not a characteristic, but it moves the object + // between controller-keyed populations, which is the read this gate + // protects. + ContinuousModification::ChangeController => CharacteristicKinds::CONTROLLER, + + // ---- CR 613.1g + CR 613.4a-d (layer 7): power/toughness. ---- + ContinuousModification::AddPower { .. } + | ContinuousModification::AddToughness { .. } + | ContinuousModification::SetPower { .. } + | ContinuousModification::SetToughness { .. } + | ContinuousModification::SetDynamicPower { .. } + | ContinuousModification::SetDynamicToughness { .. } + | ContinuousModification::SetPowerDynamic { .. } + | ContinuousModification::SetToughnessDynamic { .. } + | ContinuousModification::AddDynamicPower { .. } + | ContinuousModification::AddDynamicToughness { .. } + | ContinuousModification::SwitchPowerToughness => CharacteristicKinds::POWER_TOUGHNESS, + + // ---- CR 613.1f (layer 6): abilities. ---- + ContinuousModification::AddKeyword { .. } + | ContinuousModification::RemoveKeyword { .. } + | ContinuousModification::AddChosenKeyword + | ContinuousModification::RemoveChosenKeyword + | ContinuousModification::AddDynamicKeyword { .. } + | ContinuousModification::GrantAbility { .. } + | ContinuousModification::GrantAllActivatedAbilitiesOf { .. } + | ContinuousModification::GrantAllTriggeredAbilitiesOf { .. } + | ContinuousModification::GrantTrigger { .. } + | ContinuousModification::GrantReplacement { .. } + // CR 707.9a: retaining a printed ability through a copy still only + // rewrites the ability set, despite sitting in the copy layer. + | ContinuousModification::RetainPrintedTriggerFromSource { .. } + | ContinuousModification::RetainPrintedAbilityFromSource { .. } + | ContinuousModification::RetainAllOtherAbilitiesFromSource + // Battlefield application is a no-op (the real write happens on off-zone + // characteristics), but the truthful kind is still Abilities. + | ContinuousModification::AddKeywordWithDerivedCost { .. } + // Manufactures a `StaticDefinition` with a mode but no inner + // modifications, so there is nothing to recurse into. + | ContinuousModification::AddStaticMode { .. } => CharacteristicKinds::ABILITIES, + // CR 613.1f: grants a whole static, so it writes Abilities PLUS whatever + // the granted static's own modifications write. + ContinuousModification::GrantStaticAbility { definition } => definition + .modifications + .iter() + .fold(CharacteristicKinds::ABILITIES, |acc, inner| { + if acc.is_all() { + acc + } else { + acc.union(modification_characteristic_writes_at(inner, depth)) + } + }), + // CR 613.1f + CR 604.3: removing all abilities also strips + // characteristic-defining abilities, so it can second-order rewrite any + // kind the stripped CDA was defining. + ContinuousModification::RemoveAllAbilities => CharacteristicKinds::ALL, + + // ---- CR 613.1a + CR 707.9b (layer 1): copy effects. ---- + // A copy effect replaces the copiable values wholesale — name, mana + // cost, color, types, P/T and abilities — i.e. every kind except control + // (CR 109.3). `CopyChosen` applies as a no-op here because the real copy + // is installed as a latched `CopyValues`, but classifying it truthfully + // is free. + ContinuousModification::CopyValues { .. } | ContinuousModification::CopyChosen => { + CharacteristicKinds::ALL + } + // CR 202.1: the mana cost is only writable by copy effects; this variant + // is unreachable through the layer pipeline (its apply site asserts), but + // it is classified truthfully rather than as EMPTY. + ContinuousModification::RemoveManaCost => CharacteristicKinds::MANA_COST, + + // ---- Writes no characteristic. ---- + // CR 510.1a: combat damage ASSIGNMENT rules, not characteristics. + ContinuousModification::AssignDamageFromToughness + | ContinuousModification::AssignDamageAsThoughUnblocked + | ContinuousModification::AssignNoCombatDamage + // CR 121.1 + CR 613.1: consumed as an entry replacement at resolution and + // never reached through the layer pipeline (its apply site asserts); + // counters are not characteristics in any case. + | ContinuousModification::AddCounterOnEnter { .. } + // CR 306.5b: loyalty is read from the copiable values, not written as a + // layer effect; the apply site asserts unreachable. + | ContinuousModification::SetStartingLoyalty { .. } => CharacteristicKinds::EMPTY, + } } fn active_effects_force_incremental_escalation( @@ -3555,14 +4235,11 @@ fn active_effects_force_incremental_escalation( active_effects: &[ActiveContinuousEffect], ) -> bool { active_effects.iter().any(|e| { - let magnitude = continuous_modification_dynamic_quantity(&e.modification); - let magnitude_sensitive = - magnitude.is_some_and(crate::game::quantity::quantity_expr_uses_object_count); - let affected_sensitive = - crate::game::filter::affected_filter_uses_object_population(&e.affected_filter); + let (magnitude_sensitive, affected_sensitive) = effect_population_reads(e); if !magnitude_sensitive && !affected_sensitive { return false; } + let magnitude = continuous_modification_dynamic_quantity(&e.modification); let ctx = FilterContext::from_source_with_controller(e.source_id, e.controller); entered_ids.iter().any(|id| { let Some(entered) = state.objects.get(id) else { @@ -3585,12 +4262,23 @@ fn active_effects_force_incremental_escalation( }) } -/// Scan every live static-ability source for a CONTINUOUS `StaticDefinition` -/// whose enabling `condition` is board-population-dependent AND that one of the -/// `entered_ids` actually perturbs. Walks the same source set as -/// `collect_shared_active_continuous_effects` (`for_each_static_effect_source`) -/// and reads the source definition's `condition` field. -/// O(active-source-count × entered-count); short-circuits on the first match. +/// Scan every live continuous-effect generator for an enabling `condition` that +/// is board-population-dependent AND that one of the `entered_ids` actually +/// perturbs. Two generator channels are walked, matching the two kinds of +/// generator that carry a condition: +/// +/// * PRINTED (and granted-inner) CONTINUOUS `StaticDefinition`s, over the same +/// source set as `collect_shared_active_continuous_effects` +/// (`for_each_static_effect_source`), reading each definition's `condition`. +/// * RESOLUTION-CREATED `TransientContinuousEffect`s, reading BOTH gates that +/// [`transient_effect_is_live`] consults, via [`transient_gate_conditions`]: +/// the "for as long as" duration (CR 611.2b) and the retained enabling +/// condition. CR 611.2c locks in a resolved effect's affected SET, not +/// either gate, so both flip on entry while the frozen set goes stale with +/// them — see the transient walk below for why it has no truth-delta stage. +/// +/// O((active-source-count + transient-count) × entered-count); short-circuits on +/// the first match. /// /// Three-stage test: /// 1. The committed exhaustive classifier @@ -3675,7 +4363,39 @@ fn any_active_static_condition_perturbed_by_entry( found = true; } }); - found + if found { + return true; + } + // CR 611.2b + CR 611.2c: the walk above sees only PRINTED (and granted-inner) + // static definitions. A continuous effect created by the resolution of a + // spell or ability keeps BOTH of its gates live — CR 611.2c locks in the + // affected SET, and nothing else — so a population-dependent gate riding on + // a transient flips on entry while every recipient frozen into that effect's + // set goes stale with it. Both gates are walked through + // `transient_gate_conditions`: the "for as long as" duration is CR 611.2b + // (Master Thief), and the retained condition is the source definition's own + // CR 611.3a gate (`effects/counter.rs::apply_source_static`). + // + // No truth-delta stage here: `static_gate_truth` is keyed by + // `(source, def_index)` over printed definitions, and a transient has no + // `def_index` in that key space (several transients can share one + // `source_id`). Escalating on perturbation alone is the direction the whole + // gate is built on — over-escalation costs a full pass, under-escalation + // ships a wrong board — and it matches the recipient-context arm above, + // which also escalates on perturbation with no cache consult. + state.transient_continuous_effects.iter().any(|tce| { + // CR 109.5: a resolved spell or ability RETAINS its controller, so "you" + // in either retained gate names `tce.controller` — not whoever controls + // the source object now (that reading is only correct for static + // abilities). + let ctx = FilterContext::from_source_with_controller(tce.source_id, tce.controller); + transient_gate_conditions(tce).any(|condition| { + static_condition_uses_object_population(condition) + && entered_ids + .iter() + .any(|id| entered_object_perturbs_static_condition(state, *id, &ctx, condition)) + }) + }) } /// CR 611.3a + CR 611.3b: rewrite the source-level enabling-condition truth @@ -3821,10 +4541,16 @@ fn effect_is_restricted_to_incremental_recipients( /// visibility). It does NOT clear attribution globally or touch the rest of the /// battlefield: pre-existing objects keep their already-derived characteristics. /// -/// Caller (`flush_layers`) only reaches this path after -/// `incremental_flush_must_escalate` returned false, which guarantees no active -/// effect's magnitude or affected set reads board population — so re-deriving -/// just the entered objects yields a board identical to a full pass (CR 613.1). +/// Caller (`flush_layers`) only reaches this path when `prepare_incremental_flush` +/// returned `Some`, i.e. none of its escalation disjuncts fired. The disjuncts are +/// listed once, at that function; they are deliberately NOT restated here, because +/// a second enumeration is exactly what drifted last time. +/// +/// Within the limits those disjuncts detect, re-deriving just the entered objects +/// yields a board identical to a full pass (CR 613.1). Note what that does and +/// does not promise: the guarantee is "no DETECTED perturbation", not "no +/// population read is live". The residual blind spots are enumerated on +/// `population_probe_blinded_by_entrant_characteristic_change`. fn apply_layers_incremental(state: &mut GameState, prepared: PreparedIncrementalFlush) { let PreparedIncrementalFlush { recipient_ids, @@ -4907,8 +5633,90 @@ pub(crate) fn gather_transient_continuous_effects( } } -fn transient_duration_holds(state: &GameState, tce: &TransientContinuousEffect) -> bool { +/// CR 611.2b: the enabling condition a `"for as long as …"` DURATION carries. +/// +/// Single authority for "which condition does `tce.duration` gate on". The +/// liveness evaluator ([`transient_duration_holds`]), the read union +/// ([`live_characteristic_reads`]) and the entry-perturbation probe +/// ([`any_active_static_condition_perturbed_by_entry`]) all ask through here, +/// so a second condition-bearing `Duration` variant is wired into all three by +/// editing one function. +fn transient_duration_condition(tce: &TransientContinuousEffect) -> Option<&StaticCondition> { let Duration::ForAsLongAs { ref condition } = tce.duration else { + return None; + }; + Some(condition) +} + +/// CR 611.2b + CR 611.2c: every condition that gates whether a +/// resolution-created continuous effect is live on THIS pass. +/// +/// CR 611.2c freezes such an effect's affected SET when it begins and nothing +/// else, so both gates below stay live and can flip long after that set is +/// fixed: +/// +/// * the `"for as long as …"` DURATION (CR 611.2b — Master Thief's "gain +/// control of target artifact for as long as you control this creature"); +/// * the retained enabling CONDITION, which is the source `StaticDefinition`'s +/// own CR 611.3a gate riding along on the transient +/// (`effects/counter.rs::apply_source_static`). +/// +/// [`transient_effect_is_live`] consults this pair plus two gates that read no +/// layer-writable characteristic at all — the CR 400.7 recipient-incarnation +/// check and the `UntilHostLeavesPlay` source-zone check — which is why they +/// are outside this iterator and outside [`live_characteristic_reads`]: a zone +/// or identity change is not something layers 1-7 can write. +/// +/// Every consumer that EVALUATES whether this effect is live walks the pair +/// through here: [`transient_effect_is_live`] (via +/// [`transient_duration_holds`] and its own `tce.condition` arm), +/// [`live_characteristic_reads`] and +/// [`any_active_static_condition_perturbed_by_entry`] in this module, six +/// static-mode/protection queries in `static_abilities`, plus +/// `casting::transient_granted_spell_keywords_for`, +/// `turns::scan_step_end_mana_handlers` and +/// `visibility::viewer_may_look_at_face_down`. All but the first evaluate with +/// `evaluate_condition` rather than `source_condition_gate_passes` — the +/// authority is over WHICH conditions gate the effect, not over how a given +/// caller evaluates them. +/// +/// Two classes sit deliberately outside that claim. +/// +/// Walkers that CLASSIFY a duration without evaluating it are not consumers: +/// `analysis::resource` (two hand-destructuring sibling-mutability scans), +/// plus the `ability_rw` / `ability_scan` / `coverage` walkers. They ask what a +/// duration reads, never whether it holds, and they stay variant-safe through +/// `ability_scan`'s exhaustive matches rather than through this authority. +/// +/// Gate-blind consumers are a tracked pre-existing gap, not an exemption: +/// `casting::apply_static_activated_ability_cost_reduction` and +/// `effects::attach::protection_blocks_attachment` apply a transient effect +/// without consulting either gate, so a lapsed condition still reduces a cost +/// or blocks an attachment. Routing them through here changes behavior and +/// needs its own CR analysis and tests, so it is out of scope here. +/// +/// The grep that surfaces a new offender is `for tce in +/// &state.transient_continuous_effects` — the iteration site, not +/// `Duration::ForAsLongAs`. The latter only matches sites that already +/// destructure the duration, so it cannot see the gate-blind pair above. A new +/// iteration site that decides liveness by hand instead of calling this is the +/// regression to look for. +/// +/// One deliberate non-consumer inside this file: the lapsed-attachment sweep in +/// [`evaluate_layers`] destructures the exact `ForAsLongAs { +/// RecipientMatchesFilter { AttachedTo } }` shape (CR 301.5) to decide +/// permanent EXPIRY, not liveness. It needs the structural match, not the +/// condition list, so routing it through here would lose the thing it matches on. +pub(crate) fn transient_gate_conditions( + tce: &TransientContinuousEffect, +) -> impl Iterator { + transient_duration_condition(tce) + .into_iter() + .chain(tce.condition.as_ref()) +} + +fn transient_duration_holds(state: &GameState, tce: &TransientContinuousEffect) -> bool { + let Some(condition) = transient_duration_condition(tce) else { return true; }; @@ -20360,4 +21168,238 @@ mod tests { "a live life-reading continuous static must force full escalation" ); } + + /// CR 613.1: the characteristic a layer exists to rewrite, or `None` for + /// layer 1 (CR 613.1a — copy effects rewrite the whole copiable-value set, + /// so no single kind is implied) and for the counter sublayer (CR 613.4c — + /// counters are not continuous modifications). + fn layer_implied_kind(layer: Layer) -> Option { + match layer { + // CR 613.1a: copy effects rewrite every copiable value at once. + Layer::Copy => None, + // CR 613.1b. + Layer::Control => Some(CharacteristicKinds::CONTROLLER), + // CR 613.1c. + Layer::Text => Some(CharacteristicKinds::NAME_TEXT), + // CR 613.1d. + Layer::Type => Some(CharacteristicKinds::CARD_TYPES), + // CR 613.1e. + Layer::Color => Some(CharacteristicKinds::COLOR), + // CR 613.1f. + Layer::Ability => Some(CharacteristicKinds::ABILITIES), + // CR 613.4a-d. + Layer::CharDef | Layer::SetPT | Layer::ModifyPT | Layer::SwitchPT => { + Some(CharacteristicKinds::POWER_TOUGHNESS) + } + // CR 613.4c: no `ContinuousModification` maps here. + Layer::CounterPT => None, + } + } + + /// Every `ContinuousModification` variant that reaches the layer pipeline. + /// + /// The six omitted variants are exactly the ones whose `layer()` arm is + /// `unreachable!()` — `RemoveManaCost`, `AddCounterOnEnter`, + /// `SetStartingLoyalty` (all consumed at copy resolution) and the three + /// combat-damage assignment rules (applied after layer evaluation). They + /// have no layer to be consistent with, so the check below cannot include + /// them; their kinds are still pinned by the exhaustive `match` in + /// `modification_characteristic_writes_at`. + fn every_layered_modification() -> Vec { + use crate::types::ability::{ + ColorChangeMode, CopiableValues, CostDerivation, ReplacementDefinition, + }; + use crate::types::card_type::{CardType, SubtypeSet}; + use crate::types::keywords::{CostBearingKeywordKind, DynamicKeywordKind}; + let dynamic = QuantityExpr::Fixed { value: 1 }; + vec![ + // ---- Layer 1 (CR 613.1a). ---- + ContinuousModification::CopyValues { + values: Box::new(CopiableValues { + name: "Copy".to_string(), + mana_cost: ManaCost::default(), + color: Vec::new(), + card_types: CardType::default(), + power: None, + toughness: None, + loyalty: None, + printed_loyalty: None, + keywords: Vec::new(), + abilities: Arc::new(Vec::new()), + trigger_definitions: Arc::new(Vec::new()), + replacement_definitions: Arc::new(Vec::new()), + static_definitions: Arc::new(Vec::new()), + }), + display_source: Default::default(), + printed_ref: None, + token_image_ref: None, + }, + ContinuousModification::CopyChosen, + ContinuousModification::SetName { + name: "N".to_string(), + }, + ContinuousModification::RetainPrintedTriggerFromSource { + source_trigger_index: 0, + }, + ContinuousModification::RetainPrintedAbilityFromSource { + source_ability_index: 0, + }, + ContinuousModification::RetainAllOtherAbilitiesFromSource, + // ---- Layer 2 (CR 613.1b). ---- + ContinuousModification::ChangeController, + // ---- Layer 3 (CR 613.1c). ---- + ContinuousModification::SetTextName { + name: "N".to_string(), + }, + ContinuousModification::SetChosenName, + // ---- Layer 4 (CR 613.1d). ---- + ContinuousModification::AddType { + core_type: CoreType::Creature, + }, + ContinuousModification::RemoveType { + core_type: CoreType::Creature, + }, + ContinuousModification::SetCardTypes { + core_types: vec![CoreType::Creature], + }, + ContinuousModification::AddSubtype { + subtype: "Bear".to_string(), + }, + ContinuousModification::RemoveSubtype { + subtype: "Bear".to_string(), + }, + ContinuousModification::RemoveAllSubtypes { + set: SubtypeSet::Creature, + }, + ContinuousModification::AddAllCreatureTypes, + ContinuousModification::AddAllBasicLandTypes, + ContinuousModification::AddAllLandTypes, + ContinuousModification::AddChosenSubtype { + kind: ChosenSubtypeKind::CreatureType, + }, + ContinuousModification::AddSupertype { + supertype: Supertype::Legendary, + }, + ContinuousModification::RemoveSupertype { + supertype: Supertype::Legendary, + }, + ContinuousModification::SetBasicLandType { + land_type: BasicLandType::Forest, + }, + ContinuousModification::SetChosenBasicLandType, + // ---- Layer 5 (CR 613.1e). ---- + ContinuousModification::SetColor { + colors: vec![ManaColor::Green], + }, + ContinuousModification::AddColor { + color: ManaColor::Green, + }, + ContinuousModification::AddChosenColor { + mode: ColorChangeMode::default(), + }, + // ---- Layer 6 (CR 613.1f). ---- + ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + }, + ContinuousModification::RemoveKeyword { + keyword: Keyword::Flying, + }, + ContinuousModification::AddChosenKeyword, + ContinuousModification::RemoveChosenKeyword, + ContinuousModification::AddDynamicKeyword { + kind: DynamicKeywordKind::Annihilator, + value: dynamic.clone(), + }, + ContinuousModification::AddKeywordWithDerivedCost { + kind: CostBearingKeywordKind::Foretell, + derivation: CostDerivation::ManaCostReducedBy(ManaCost::default()), + }, + ContinuousModification::GrantAbility { + definition: Box::new(AbilityDefinition::new( + AbilityKind::Activated, + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + )), + }, + ContinuousModification::GrantAllActivatedAbilitiesOf { + source: TargetFilter::SelfRef, + cap: None, + }, + ContinuousModification::GrantAllTriggeredAbilitiesOf { + source: TargetFilter::SelfRef, + }, + ContinuousModification::GrantTrigger { + trigger: Box::new(TriggerDefinition::new(TriggerMode::ChangesZone)), + }, + ContinuousModification::GrantReplacement { + replacement: Box::new(ReplacementDefinition::new(ReplacementEvent::DamageDone)), + }, + ContinuousModification::RemoveAllAbilities, + ContinuousModification::AddStaticMode { + mode: StaticMode::Continuous, + }, + ContinuousModification::GrantStaticAbility { + definition: Box::new(StaticDefinition::new(StaticMode::Continuous)), + }, + // ---- Layer 7 (CR 613.4a-d). ---- + ContinuousModification::SetDynamicPower { + value: dynamic.clone(), + }, + ContinuousModification::SetDynamicToughness { + value: dynamic.clone(), + }, + ContinuousModification::SetPower { value: 1 }, + ContinuousModification::SetToughness { value: 1 }, + ContinuousModification::SetPowerDynamic { + value: dynamic.clone(), + }, + ContinuousModification::SetToughnessDynamic { + value: dynamic.clone(), + }, + ContinuousModification::AddPower { value: 1 }, + ContinuousModification::AddToughness { value: 1 }, + ContinuousModification::AddDynamicPower { + value: dynamic.clone(), + }, + ContinuousModification::AddDynamicToughness { value: dynamic }, + ContinuousModification::SwitchPowerToughness, + ] + } + + /// CR 613.1: a modification's declared LAYER and its declared WRITE KIND + /// are two views of the same fact, so they must agree — an effect applied + /// in the type-changing layer must report that it writes card types, and so + /// on. This is the second tripwire on `modification_characteristic_writes`: + /// the exhaustive wildcard-free `match` catches a variant that was never + /// classified, and this catches one that was classified into the WRONG + /// kind (which the gate would silently under-escalate on). + /// + /// Both directions are only a containment, not an equality: a modification + /// may write MORE than its layer implies. CR 305.7's `SetBasicLandType` + /// sits in layer 4 but also strips abilities, and CR 613.1f's + /// `RemoveAllAbilities` sits in layer 6 but is classified `ALL` because it + /// turns off characteristic-defining abilities (CR 604.3). + #[test] + fn modification_write_kinds_agree_with_their_layer() { + for m in every_layered_modification() { + let writes = modification_characteristic_writes(&m); + let layer = m.layer(); + let Some(implied) = layer_implied_kind(layer) else { + // Layer 1: no single implied kind, but a copy effect always + // rewrites SOMETHING. + assert!( + !writes.is_empty(), + "{m:?} is a layer-1 copy effect but was classified as writing nothing" + ); + continue; + }; + assert!( + writes.contains(implied), + "{m:?} is applied in {layer:?} but its write kinds {writes:?} do not \ + include the characteristic that layer exists to rewrite ({implied:?})" + ); + } + } } diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 8bc7593492..d9ea4186dc 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -11,7 +11,8 @@ use crate::game::filter::{ matches_target_filter, matches_target_filter_on_attack_declaration_record, matches_target_filter_on_counter_added_record, matches_target_filter_on_damage_record_source, matches_target_filter_on_zone_change_record, player_matches_target_filter_in_state, - spell_record_matches_filter, type_filter_matches, FilterContext, + shared_quality_characteristic_reads, spell_record_matches_filter, + target_filter_characteristic_reads_at, type_filter_matches, CharacteristicKinds, FilterContext, }; use crate::game::speed::effective_speed; use crate::types::ability::{ @@ -999,6 +1000,267 @@ fn quantity_ref_uses_object_count(qty: &QuantityRef) -> bool { } } +/// CR 613.1: Which layer-writable characteristic kinds does this magnitude read? +/// +/// Structural twin of `quantity_expr_uses_object_count`: that predicate answers +/// "can board MEMBERSHIP change this magnitude"; this one answers "which +/// layer-writable CHARACTERISTICS does it read". Both are needed — a count of +/// tapped permanents is population-sensitive but reads no layer-written kind. +pub(crate) fn quantity_expr_characteristic_reads_at( + expr: &QuantityExpr, + depth: u32, +) -> CharacteristicKinds { + let Some(depth) = depth.checked_sub(1) else { + return CharacteristicKinds::ALL; + }; + match expr { + QuantityExpr::Fixed { .. } => CharacteristicKinds::EMPTY, + QuantityExpr::Ref { qty } => quantity_ref_characteristic_reads(qty, depth), + QuantityExpr::DivideRounded { inner, .. } + | QuantityExpr::Offset { inner, .. } + | QuantityExpr::ClampMin { inner, .. } + | QuantityExpr::Multiply { inner, .. } => { + quantity_expr_characteristic_reads_at(inner, depth) + } + QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => { + exprs.iter().fold(CharacteristicKinds::EMPTY, |acc, e| { + if acc.is_all() { + acc + } else { + acc.union(quantity_expr_characteristic_reads_at(e, depth)) + } + }) + } + QuantityExpr::UpTo { max } => quantity_expr_characteristic_reads_at(max, depth), + QuantityExpr::Power { exponent, .. } => { + quantity_expr_characteristic_reads_at(exponent, depth) + } + QuantityExpr::Difference { left, right } => { + quantity_expr_characteristic_reads_at(left, depth) + .union(quantity_expr_characteristic_reads_at(right, depth)) + } + } +} + +/// CR 613.1: Leaf classification for [`quantity_expr_characteristic_reads_at`]. +/// EXHAUSTIVE and wildcard-free over `QuantityRef`, arm-for-arm with +/// `quantity_ref_uses_object_count`, so a new variant forces both decisions. +/// +/// Two payload rules apply on top of each arm's intrinsic reads: +/// 1. every arm whose count is taken over a LIVE object filter recurses into +/// that filter, because the filter's own predicates read characteristics; +/// 2. every arm carrying a `ControllerRef` unions +/// [`CharacteristicKinds::CONTROLLER`], because CR 613.1b can move objects +/// across the scope the reference is asking about. +/// +/// Arms that read a FROZEN per-turn history record (sacrificed / attacked / +/// zone-change / damage / token-creation journals) read no live characteristic: +/// those records store the object's characteristics as of the recorded event, so +/// no later layer write can change the tally. They classify EMPTY, and their +/// embedded filters are deliberately NOT recursed. +fn quantity_ref_characteristic_reads(qty: &QuantityRef, depth: u32) -> CharacteristicKinds { + match qty { + // ---- Live object censuses: recurse the counted filter. ---- + QuantityRef::ObjectCount { filter } + | QuantityRef::CountersOnObjects { filter, .. } + | QuantityRef::EnteredThisTurn { filter } + // CR 403.3: the tally is a per-turn journal, but its filter is matched + // against LIVE objects, so the filter's reads count. + | QuantityRef::BattlefieldEntriesThisTurn { filter, .. } + // CR 122.1: counter kinds are not layer-written; only the filter reads. + | QuantityRef::DistinctCounterKindsAmong { filter } => { + target_filter_characteristic_reads_at(filter, depth) + } + // CR 608.2c: tracked-set members are addressed by identity and matched live. + QuantityRef::FilteredTrackedSetSize { filter, .. } => { + target_filter_characteristic_reads_at(filter, depth) + } + // CR 201.2 + CR 603.4: dedupe key is a characteristic read of its own. + QuantityRef::ObjectCountDistinct { filter, qualities } => qualities + .iter() + .fold( + target_filter_characteristic_reads_at(filter, depth), + |acc, q| acc.union(shared_quality_characteristic_reads(q)), + ), + // CR 109.3 + CR 205.3m: grouping key is a characteristic read. + QuantityRef::ObjectCountBySharedQuality { + filter, quality, .. + } => target_filter_characteristic_reads_at(filter, depth) + .union(shared_quality_characteristic_reads(quality)), + // CR 202.3: aggregated object property plus the scanned filter. + QuantityRef::Aggregate { + property, filter, .. + } => object_property_characteristic_reads(property) + .union(target_filter_characteristic_reads_at(filter, depth)), + // CR 109.5 + CR 613.1b: per-player partition of a live census. + QuantityRef::ControlledByEachPlayer { filter, .. } => CharacteristicKinds::CONTROLLER + .union(target_filter_characteristic_reads_at(filter, depth)), + // CR 106.1 + CR 109.1: distinct colors over a live census. + QuantityRef::DistinctColorsAmongPermanents { filter } => CharacteristicKinds::COLOR + .union(target_filter_characteristic_reads_at(filter, depth)), + // CR 205.2a / CR 205.3: the object-filter source scans live objects; the + // zone / linked-exile / tracked-set sources do not read a live filter. + QuantityRef::DistinctCardTypes { source } + | QuantityRef::DistinctSubtypes { source, .. } => { + CharacteristicKinds::CARD_TYPES.union(match source { + CardTypeSetSource::Objects { filter, .. } => { + target_filter_characteristic_reads_at(filter, depth) + } + CardTypeSetSource::Zone { .. } + | CardTypeSetSource::ExiledBySource + | CardTypeSetSource::TrackedSet { .. } => CharacteristicKinds::EMPTY, + }) + } + // CR 604.3: a zone census, filtered by typeline and by an optional + // filter, scoped by controller. + QuantityRef::ZoneCardCount { + card_types, filter, .. + } => { + let mut kinds = CharacteristicKinds::CONTROLLER; + if !card_types.is_empty() { + kinds = kinds.union(CharacteristicKinds::CARD_TYPES); + } + filter.as_ref().map_or(kinds, |f| { + kinds.union(target_filter_characteristic_reads_at(f, depth)) + }) + } + // CR 700.8: party reads Cleric/Rogue/Warrior/Wizard creature types among + // the scoped player's creatures — CR 702.73a Changeling applies. + QuantityRef::PartySize { .. } => CharacteristicKinds::CARD_TYPES + .union(CharacteristicKinds::ABILITIES) + .union(CharacteristicKinds::CONTROLLER), + // CR 305.6: distinct basic land types among the referenced player's + // lands. Carries a `ControllerRef` (payload rule 2). + QuantityRef::BasicLandTypeCount { .. } => { + CharacteristicKinds::CARD_TYPES.union(CharacteristicKinds::CONTROLLER) + } + // CR 700.5: devotion counts mana symbols in the mana costs of the + // permanents the scoped player controls. + QuantityRef::Devotion { .. } => { + CharacteristicKinds::MANA_COST.union(CharacteristicKinds::CONTROLLER) + } + // CR 903.3d: mana value of a commander, scoped by a `ControllerRef`. + QuantityRef::CommanderManaValue { .. } => { + CharacteristicKinds::MANA_COST.union(CharacteristicKinds::CONTROLLER) + } + + // ---- Single-object characteristic reads. ---- + // CR 208.1 / CR 209.1. + QuantityRef::Power { .. } | QuantityRef::Toughness { .. } => { + 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. + QuantityRef::ObjectManaValue { .. } + | QuantityRef::ManaSymbolsInManaCost { .. } + | QuantityRef::SelfManaValue => CharacteristicKinds::MANA_COST, + // CR 202.3 + CR 115.1: mana value of the object chosen for this ref's own + // target slot, whose candidates are `filter`. + QuantityRef::TargetObjectManaValue { filter } => CharacteristicKinds::MANA_COST + .union(target_filter_characteristic_reads_at(filter, depth)), + // CR 105.1 + CR 105.2. + QuantityRef::ObjectColorCount { .. } => CharacteristicKinds::COLOR, + // CR 201.1 + CR 201.2. + QuantityRef::ObjectNameWordCount { .. } => CharacteristicKinds::NAME_TEXT, + // CR 205.4a + CR 205.2a + CR 205.3: supertypes + card types + subtypes. + QuantityRef::ObjectTypelineComponentCount { .. } => CharacteristicKinds::CARD_TYPES, + // CR 608.2c: aggregates an object property over an identity-addressed set. + QuantityRef::TrackedSetAggregate { property, .. } => { + object_property_characteristic_reads(property) + } + // CR 122.1f + CR 109.4: reads the controller of the parent target. + QuantityRef::TargetControllerCounter { .. } => CharacteristicKinds::CONTROLLER, + // CR 400.7 + CR 613.1b: look-back attachment snapshot, optionally scoped + // by a `ControllerRef` (payload rule 2). + QuantityRef::AttachmentsOnLeavingObject { .. } => CharacteristicKinds::CONTROLLER, + + // ---- Reads no layer-writable characteristic. ---- + // Player-level totals, counters (CR 122.1 — counters are not + // characteristics), payments, choices, and the FROZEN per-turn / + // per-game history journals described in the doc comment. Enumerated + // explicitly (no wildcard). + QuantityRef::HandSize { .. } + | QuantityRef::LifeTotal { .. } + | QuantityRef::GraveyardSize { .. } + | QuantityRef::LifeAboveStarting + | QuantityRef::StartingLifeTotal + | QuantityRef::TriggeringDiscoverValue + | QuantityRef::TriggeringScryLookCount + | QuantityRef::TriggeringScryBottomCount + | QuantityRef::PlayerCount { .. } + | QuantityRef::EventContextPlayerCount { .. } + | QuantityRef::CountersOn { .. } + | QuantityRef::PlayerCounter { .. } + | QuantityRef::Variable { .. } + // Digital-only Alchemy counter-like value; no layer writes it. + | QuantityRef::Intensity { .. } + | QuantityRef::TargetZoneCardCount { .. } + | QuantityRef::CardsExiledBySource + | QuantityRef::TrackedSetSize + | QuantityRef::ExiledFromHandThisResolution + | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::LifeLostThisTurn { .. } + | QuantityRef::UnspentMana { .. } + | QuantityRef::Speed { .. } + | QuantityRef::EventContextAmount + | QuantityRef::EventContextSourceCostX + | QuantityRef::EventContextSourceModesChosen + // CR 117.1: spell-cast journals store each spell's cast-time + // characteristics. + | QuantityRef::SpellsCastThisTurn { .. } + | QuantityRef::SpellsCastThisGame { .. } + // CR 701.16a: sacrifice-time characteristics. + | QuantityRef::SacrificedThisTurn { .. } + | QuantityRef::CrimesCommittedThisTurn + | QuantityRef::BendTypesThisTurn + | QuantityRef::LifeGainedThisTurn { .. } + | QuantityRef::CardsDrawnThisTurn { .. } + | QuantityRef::LandsPlayedThisTurn { .. } + | QuantityRef::TurnsTaken + // CR 400.7 + CR 700.4: zone-change records store last-known information. + | QuantityRef::ZoneChangeCountThisTurn { .. } + | QuantityRef::ZoneChangeAggregateThisTurn { .. } + // CR 120.1: damage records store the amount actually dealt. + | QuantityRef::DamageDealtThisTurn { .. } + | QuantityRef::ChosenNumber + // CR 508.1: declaration-time attacker snapshots. + | QuantityRef::AttackedThisTurn { .. } + | QuantityRef::DescendedThisTurn + | QuantityRef::LoyaltyAbilitiesActivatedThisTurn { .. } + | QuantityRef::SpellsCastLastTurn + // CR 122.1: counter-addition journal. + | QuantityRef::CounterAddedThisTurn { .. } + | QuantityRef::CardsDiscardedThisTurn { .. } + // CR 111.2: creation-time token characteristics. + | QuantityRef::TokensCreatedThisTurn { .. } + | QuantityRef::PlayerActionsThisTurn { .. } + | QuantityRef::DungeonsCompleted + | QuantityRef::CostXPaid + | QuantityRef::KickerCount + | QuantityRef::AdditionalCostPaymentCount + | QuantityRef::AdditionalCostPaymentCountFor { .. } + | QuantityRef::ConvokedCreatureCount + | QuantityRef::TimesCostPaidThisResolution + | QuantityRef::ManaSpentToCast { .. } + // CR 903.4: color identity is fixed by the printed card. + | QuantityRef::ColorsInCommandersColorIdentity + | QuantityRef::CommanderCastFromCommandZoneCount + | QuantityRef::VoteCount { .. } => CharacteristicKinds::EMPTY, + } +} + +/// CR 208.1 + CR 209.1 + CR 202.3 + CR 107.4a: which characteristic an +/// aggregated object property reads. +fn object_property_characteristic_reads(property: &ObjectProperty) -> CharacteristicKinds { + match property { + ObjectProperty::Power | ObjectProperty::Toughness => CharacteristicKinds::POWER_TOUGHNESS, + ObjectProperty::ManaValue | ObjectProperty::ManaSymbolCount(_) => { + CharacteristicKinds::MANA_COST + } + } +} + /// CR 611.3a + CR 700.5: ENTRY-AWARE narrowing for a population-sensitive /// magnitude. `quantity_expr_uses_object_count` proves an effect's magnitude /// *can* read board population; this proves a SPECIFIC entering object can diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 94609c5a05..29402e7b38 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -10135,10 +10135,20 @@ mod tests { /// mark layers entered. Flips no devotion / land-presence gate and matches /// no artifact/land filter. fn add_colorless_creature_entry(state: &mut GameState, card_id: u64) -> ObjectId { + add_colorless_creature_entry_under(state, card_id, PlayerId(0)) + } + + /// The same entrant under an explicit controller, for controller-keyed + /// population fixtures (CR 613.1b). + fn add_colorless_creature_entry_under( + state: &mut GameState, + card_id: u64, + controller: PlayerId, + ) -> ObjectId { let id = create_object( state, CardId(card_id), - PlayerId(0), + controller, "Insect".to_string(), Zone::Battlefield, ); @@ -10421,6 +10431,404 @@ mod tests { assert_pt_identical(&normal, &forced, "count-anthem matching escalation"); } + /// Build a board pairing a GREEN-keyed count magnitude with a color + /// wash: one enchantment carries "creatures get +X/+X, X = number of + /// green creatures" AND "creatures are green in addition to their other + /// colors" (layer 5). Two pre-existing 2/2 Bears — green via the wash, + /// so the count starts at 2 and both flush to 4/4. + fn green_count_anthem_with_color_wash_board() -> GameState { + use crate::types::ability::{ContinuousModification, StaticDefinition}; + use crate::types::statics::StaticMode; + let mut state = setup(); + for i in 0..2 { + let id = create_object( + &mut state, + CardId(280 + i), + PlayerId(0), + format!("WashBear{i}"), + Zone::Battlefield, + ); + let o = state.objects.get_mut(&id).unwrap(); + o.base_power = Some(2); + o.base_toughness = Some(2); + o.power = Some(2); + o.toughness = Some(2); + o.base_card_types.core_types = vec![CoreType::Creature]; + o.card_types.core_types = vec![CoreType::Creature]; + o.base_color = vec![]; + o.color = vec![]; + } + let anthem = create_object( + &mut state, + CardId(290), + PlayerId(0), + "Color Wash Count Anthem".to_string(), + Zone::Battlefield, + ); + // "creatures are green in addition to their other colors" (layer 5). + let mut wash = StaticDefinition::new(StaticMode::Continuous); + wash.affected = Some(TargetFilter::Typed(TypedFilter::creature())); + wash.modifications = vec![ContinuousModification::AddColor { + color: ManaColor::Green, + }]; + // "creatures get +X/+X, X = number of green creatures" (layer 7c). + let green_creatures = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::HasColor { + color: ManaColor::Green, + }], + ..Default::default() + }); + let mut count = StaticDefinition::new(StaticMode::Continuous); + count.affected = Some(TargetFilter::Typed(TypedFilter::creature())); + count.modifications = vec![ + ContinuousModification::AddDynamicPower { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: green_creatures.clone(), + }, + }, + }, + ContinuousModification::AddDynamicToughness { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: green_creatures, + }, + }, + }, + ]; + { + let o = state.objects.get_mut(&anthem).unwrap(); + o.base_static_definitions = Arc::new(vec![wash.clone(), count.clone()]); + o.static_definitions = vec![wash, count].into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + } + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + /// COLOR channel (CR 613.1e + CR 613.1g): a population keyed on COLOR + /// whose entrant has that color rewritten by another layer must + /// escalate. A layer-5 `AddColor` washes the colorless entrant green + /// before the layer-7c count applies, so a pre-layer probe would see a + /// colorless entrant, keep the incremental arm, and leave pre-existing + /// Bears at a stale 4/4 where the correct CR 613 board is 5/5. + /// `modification_characteristic_writes` classifies the color writers as + /// `CharacteristicKinds::COLOR` and the counted filter reads the same + /// kind, so the sets intersect, the gate escalates and the two boards + /// agree. + /// + /// This is the discriminating fixture for that channel: revert the + /// `COLOR` arm of the classifier and the escalation assertion fails; keep + /// the arm but break the escalation plumbing and the identity assertion + /// fails on the Bears' derived power/toughness. + #[test] + fn color_change_entry_escalates_when_population_is_color_keyed() { + let (normal, escalated, forced) = + flush_entry_and_forced(green_count_anthem_with_color_wash_board, |s| { + add_colorless_creature_entry(s, 291) + }); + assert!( + escalated, + "a layer-5 color wash reaching the entrant moves a color-keyed \ + count — the entry must escalate to a full re-evaluation" + ); + let bear_pts = |state: &GameState| { + let mut pts: Vec<(Option, Option)> = state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .filter(|o| o.name.starts_with("WashBear")) + .map(|o| (o.power, o.toughness)) + .collect(); + pts.sort(); + pts + }; + // The washed entrant is green by the time the count applies, so the + // count is 3, not the pre-layer 2. + assert_eq!( + bear_pts(&forced), + vec![(Some(5), Some(5)); 2], + "full pass counts the washed entrant — the correct CR 613 board" + ); + assert_eq!( + bear_pts(&normal), + bear_pts(&forced), + "escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "color-keyed population escalation"); + } + + /// Build a board pairing a PURE layer-4 type-writer with a + /// condition-gated fixed anthem — the CONDITION-channel analogue of the + /// Ashaya CDA regression. Source A: "creatures you control are lands in + /// addition to their other types" (no dynamic magnitude, no + /// population-sensitive affected set, no entry replacement). Source B: + /// "creatures you control get +2/+2 as long as you control four or more + /// lands" (`QuantityComparison` over `ObjectCount(Land)` — the ONLY + /// population read on the board, and it lives in a `condition`, not in + /// any effect's magnitude or affected set). One pre-existing GateBear, + /// two plain lands: pre-entry land count = 2 lands + 1 creature-as-land + /// = 3, gate OFF. + fn type_writer_with_condition_gated_anthem_board() -> GameState { + use crate::types::ability::{ + Comparator, ContinuousModification, StaticCondition, StaticDefinition, + TypeFilter as TF, TypedFilter as TFil, + }; + use crate::types::statics::StaticMode; + let mut state = setup(); + let bear = create_object( + &mut state, + CardId(300), + PlayerId(0), + "GateBear".to_string(), + Zone::Battlefield, + ); + { + let o = state.objects.get_mut(&bear).unwrap(); + o.base_power = Some(2); + o.base_toughness = Some(2); + o.power = Some(2); + o.toughness = Some(2); + o.base_card_types.core_types = vec![CoreType::Creature]; + o.card_types.core_types = vec![CoreType::Creature]; + } + for i in 0..2 { + let land = create_object( + &mut state, + CardId(301 + i), + PlayerId(0), + format!("QuietLand{i}"), + Zone::Battlefield, + ); + let o = state.objects.get_mut(&land).unwrap(); + o.base_card_types.core_types = vec![CoreType::Land]; + o.card_types.core_types = vec![CoreType::Land]; + } + let type_writer = create_object( + &mut state, + CardId(310), + PlayerId(0), + "Creatures Are Lands".to_string(), + Zone::Battlefield, + ); + let mut writer_sd = StaticDefinition::new(StaticMode::Continuous); + writer_sd.affected = Some(TargetFilter::Typed(TFil::new(TF::Creature))); + writer_sd.modifications = vec![ContinuousModification::AddType { + core_type: CoreType::Land, + }]; + { + let o = state.objects.get_mut(&type_writer).unwrap(); + o.base_static_definitions = Arc::new(vec![writer_sd.clone()]); + o.static_definitions = vec![writer_sd].into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + } + let anthem = create_object( + &mut state, + CardId(311), + PlayerId(0), + "Land Threshold Anthem".to_string(), + Zone::Battlefield, + ); + let mut anthem_sd = StaticDefinition::new(StaticMode::Continuous); + anthem_sd.affected = Some(TargetFilter::Typed(TFil::new(TF::Creature))); + anthem_sd.modifications = vec![ + ContinuousModification::AddPower { value: 2 }, + ContinuousModification::AddToughness { value: 2 }, + ]; + anthem_sd.condition = Some(StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(TFil::new(TF::Land)), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 4 }, + }); + { + let o = state.objects.get_mut(&anthem).unwrap(); + o.base_static_definitions = Arc::new(vec![anthem_sd.clone()]); + o.static_definitions = vec![anthem_sd].into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + } + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + /// (3c) CONDITION-channel blindness — perturbing only POST-layer: + /// CR 611.3a + CR 613.1 + CR 613.1d. A plain creature entering is NOT a + /// land at gate time, so neither Axis 2a (no population-reading effect + /// magnitude or affected set is live) nor Axis 2b's pre-layer membership + /// probe fires; but the type-writer makes the entrant a land in layer 4 + /// and the count crosses the anthem's threshold, changing PRE-EXISTING + /// recipients. The blindness disjunct's condition channel + /// (`static_condition_characteristic_reads`, unioned into `ReadKinds` by + /// `live_characteristic_reads`) MUST escalate this entry, and the board + /// must match a forced-Full pass (GateBear + /// 4/4, not stale 2/2). + #[test] + fn condition_gated_anthem_entry_escalates_when_entrant_types_rewritten() { + let (normal, escalated, forced) = + flush_entry_and_forced(type_writer_with_condition_gated_anthem_board, |s| { + add_colorless_creature_entry(s, 320) + }); + assert!( + escalated, + "entrant becomes a land in layer 4 and flips the threshold gate — must escalate" + ); + assert_pt_identical( + &normal, + &forced, + "condition-channel type-rewrite escalation", + ); + // Vacuity guard: `escalated` depends only on the classifier, so pin + // that the threshold gate actually flips — GateBear ends the pass + // at 4/4 (base 2/2 + the now-live +2/+2), not a stale 2/2. + let gatebear_pt = |state: &GameState| { + state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .find(|o| o.name == "GateBear") + .map(|o| (o.power, o.toughness)) + .unwrap() + }; + assert_eq!( + gatebear_pt(&forced), + (Some(4), Some(4)), + "the entrant-turned-land crosses the GE-4 land threshold" + ); + } + + /// Board for the CONTROLLER channel (CR 613.1b). P0 controls two 2/2s + /// and an anthem whose dynamic magnitude counts "creatures you control", + /// plus a theft enchantment whose layer-2 `ChangeController` claims + /// every creature for the enchantment's controller. The theft's affected + /// filter is deliberately controller-FREE: a controller-keyed affected + /// filter would make layer 2 self-referential, and the point under test + /// is the counted population, not the affected set. + fn controller_keyed_count_anthem_with_control_theft_board() -> GameState { + use crate::types::ability::{ + ContinuousModification, ControllerRef, StaticDefinition, TypeFilter as TF, + TypedFilter as TFil, + }; + use crate::types::statics::StaticMode; + let mut state = setup(); + for i in 0..2 { + let id = create_object( + &mut state, + CardId(380 + i), + PlayerId(0), + format!("TheftBear{i}"), + Zone::Battlefield, + ); + let o = state.objects.get_mut(&id).unwrap(); + o.base_power = Some(2); + o.base_toughness = Some(2); + o.power = Some(2); + o.toughness = Some(2); + o.base_card_types.core_types = vec![CoreType::Creature]; + o.card_types.core_types = vec![CoreType::Creature]; + } + let yours = TargetFilter::Typed(TFil { + type_filters: vec![TF::Creature], + controller: Some(ControllerRef::You), + ..Default::default() + }); + let anthem = create_object( + &mut state, + CardId(385), + PlayerId(0), + "Ally Count Anthem".to_string(), + Zone::Battlefield, + ); + let mut anthem_sd = StaticDefinition::new(StaticMode::Continuous); + anthem_sd.affected = Some(TargetFilter::Typed(TFil::new(TF::Creature))); + anthem_sd.modifications = vec![ + ContinuousModification::AddDynamicPower { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: yours.clone(), + }, + }, + }, + ContinuousModification::AddDynamicToughness { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { filter: yours }, + }, + }, + ]; + { + let o = state.objects.get_mut(&anthem).unwrap(); + o.base_static_definitions = Arc::new(vec![anthem_sd.clone()]); + o.static_definitions = vec![anthem_sd].into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + } + let thief = create_object( + &mut state, + CardId(386), + PlayerId(0), + "Mass Mind Control".to_string(), + Zone::Battlefield, + ); + let mut theft_sd = StaticDefinition::new(StaticMode::Continuous); + theft_sd.affected = Some(TargetFilter::Typed(TFil::new(TF::Creature))); + theft_sd.modifications = vec![ContinuousModification::ChangeController]; + { + let o = state.objects.get_mut(&thief).unwrap(); + o.base_static_definitions = Arc::new(vec![theft_sd.clone()]); + o.static_definitions = vec![theft_sd].into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + } + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + /// (3d) CONTROLLER-channel blindness — CR 613.1b + CR 613.1 + CR 109.3. + /// The entrant arrives under the OPPONENT, so a pre-layer probe of + /// "creatures you control" says the count is unperturbed; layer 2 then + /// hands the entrant to the anthem's controller and the count goes + /// 2 -> 3, which moves PRE-EXISTING recipients. CR 109.3 puts controller + /// outside an object's characteristics, so this is not reachable by the + /// card-type disjunct — `modification_characteristic_writes` has to + /// classify `ChangeController` as a `CharacteristicKinds::CONTROLLER` + /// write in its own right for this entry to escalate. + #[test] + fn controller_change_entry_escalates_when_population_is_controller_keyed() { + let (normal, escalated, forced) = flush_entry_and_forced( + controller_keyed_count_anthem_with_control_theft_board, + |s| add_colorless_creature_entry_under(s, 390, PlayerId(1)), + ); + assert!( + escalated, + "layer 2 moves the entrant into the counted population — must escalate" + ); + assert_pt_identical(&normal, &forced, "controller-channel escalation"); + // Vacuity guard: `escalated` depends only on the classifier, so pin + // that the stolen entrant really does move the count — TheftBears + // end the pass at 5/5 (base 2/2 + three creatures now controlled), + // not the pre-layer 4/4. + let theftbear_pt = |state: &GameState| { + state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .find(|o| o.name == "TheftBear0") + .map(|o| (o.power, o.toughness)) + .unwrap() + }; + assert_eq!( + theftbear_pt(&forced), + (Some(5), Some(5)), + "the stolen entrant is counted among \"creatures you control\"" + ); + } + /// (4) MEDIUM-2 — whole-board TALLY affected filter /// (`MostPrevalentCreatureTypeIn`). The anthem affects "creatures of the /// most prevalent creature type on the battlefield". A creature token @@ -10899,6 +11307,1355 @@ mod tests { ); } + // ------------------------------------------------------------------ + // Read/write-kind relation fixtures (CR 613.1). + // + // Each board mirrors the color-wash fixture's shape: one enchantment + // carrying TWO Continuous static definitions, a vanilla entrant (so + // `entered_object_blocks_incremental` stays quiet), and a divergence + // that surfaces in power/toughness (all `assert_pt_identical` compares). + // + // Non-vacuity invariants, checked per fixture: the entrant must NOT + // satisfy the population-sensitive read PRE-layer (otherwise Axis 2a + // escalates and the kind relation goes untested), it must satisfy it + // POST-layer, and the writer's layer must run strictly before the + // reading layer. + // ------------------------------------------------------------------ + + /// Install a battlefield enchantment carrying `defs` as both its base + /// and its live static definitions, matching how the pre-existing + /// escalation boards install anthems. + fn install_static_enchantment( + state: &mut GameState, + card_id: u64, + name: &str, + defs: Vec, + ) -> ObjectId { + let id = create_object( + state, + CardId(card_id), + PlayerId(0), + name.to_string(), + Zone::Battlefield, + ); + let o = state.objects.get_mut(&id).unwrap(); + o.base_static_definitions = Arc::new(defs.clone()); + o.static_definitions = defs.into(); + o.base_card_types.core_types = vec![CoreType::Enchantment]; + o.card_types.core_types = vec![CoreType::Enchantment]; + id + } + + /// Create a vanilla 2/2 creature with an explicit name and color set. + fn add_relation_bear( + state: &mut GameState, + card_id: u64, + name: &str, + colors: Vec, + ) -> ObjectId { + let id = create_object( + state, + CardId(card_id), + PlayerId(0), + name.to_string(), + Zone::Battlefield, + ); + let o = state.objects.get_mut(&id).unwrap(); + o.base_power = Some(2); + o.base_toughness = Some(2); + o.power = Some(2); + o.toughness = Some(2); + o.base_card_types.core_types = vec![CoreType::Creature]; + o.card_types.core_types = vec![CoreType::Creature]; + o.base_color.clone_from(&colors); + o.color = colors; + id + } + + /// Sorted `(power, toughness)` of every battlefield object whose name + /// starts with `prefix`. + fn pts_named(state: &GameState, prefix: &str) -> Vec<(Option, Option)> { + let mut pts: Vec<(Option, Option)> = state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .filter(|o| o.name.starts_with(prefix)) + .map(|o| (o.power, o.toughness)) + .collect(); + pts.sort(); + pts + } + + /// `pts_named`'s twin for boards where a layer-1 `SetName` override + /// (CR 707.9b) rewrites the live name: selects on the PRINTED name, + /// which `reset_recipient_to_base` restores at the top of every pass. + fn pts_base_named(state: &GameState, prefix: &str) -> Vec<(Option, Option)> { + let mut pts: Vec<(Option, Option)> = state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .filter(|o| o.base_name.starts_with(prefix)) + .map(|o| (o.power, o.toughness)) + .collect(); + pts.sort(); + pts + } + + /// A `Continuous` static definition over `affected` applying `mods`. + fn continuous_static( + affected: TargetFilter, + mods: Vec, + ) -> crate::types::ability::StaticDefinition { + let mut def = crate::types::ability::StaticDefinition::new( + crate::types::statics::StaticMode::Continuous, + ); + def.affected = Some(affected); + def.modifications = mods; + def + } + + /// `AddDynamicPower` + `AddDynamicToughness` off one `ObjectCount`. + fn dynamic_pt_count( + counted: TargetFilter, + ) -> Vec { + use crate::types::ability::ContinuousModification; + let count = QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { filter: counted }, + }; + vec![ + ContinuousModification::AddDynamicPower { + value: count.clone(), + }, + ContinuousModification::AddDynamicToughness { value: count }, + ] + } + + /// (2.1) KEYWORD channel (CR 613.1f). A layer-6 `AddKeyword` reaches the + /// entrant while the anthem's magnitude counts creatures WITH that + /// keyword. Pre-layer the entrant has no flying, so every membership + /// probe reports "no perturbation"; post-layer it does, so the count + /// moves 2 → 3 and the pre-existing FlyBears go 4/4 → 5/5. + /// + /// Discriminating for BOTH halves of the relation: revert the + /// `AddKeyword` family to EMPTY on the write side, or the `WithKeyword` + /// family to EMPTY on the read side, and the escalation assertion fails. + fn flying_count_anthem_with_keyword_grant_board() -> GameState { + use crate::types::ability::ContinuousModification; + use crate::types::Keyword; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 600 + i, &format!("FlyBear{i}"), vec![]); + } + let grant = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + }], + ); + let flying_creatures = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::WithKeyword { + value: Keyword::Flying, + }], + ..Default::default() + }); + let count = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + dynamic_pt_count(flying_creatures), + ); + install_static_enchantment(&mut state, 610, "Flying Count Anthem", vec![grant, count]); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn keyword_grant_entry_escalates_when_population_is_keyword_keyed() { + let (normal, escalated, forced) = + flush_entry_and_forced(flying_count_anthem_with_keyword_grant_board, |s| { + add_colorless_creature_entry(s, 611) + }); + assert!( + escalated, + "a layer-6 keyword grant reaching the entrant moves a keyword-keyed \ + count — the entry must escalate to a full re-evaluation" + ); + assert_eq!( + pts_named(&forced, "FlyBear"), + vec![(Some(5), Some(5)); 2], + "full pass counts the entrant once it has flying — correct CR 613 board" + ); + assert_eq!( + pts_named(&normal, "FlyBear"), + pts_named(&forced, "FlyBear"), + "escalated entry must derive the same board as full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "keyword-keyed escalation"); + } + + /// (2.2) POWER/TOUGHNESS channel (CR 613.1g + CR 613.4b/c). A layer-7b + /// `SetToughness` reaches the entrant while the anthem's magnitude counts + /// creatures with toughness ≥ 4 at layer 7c. + /// + /// Because power/toughness is ONE kind, a P/T-keyed count anthem is + /// itself a P/T writer and would satisfy the relation on its own reach. + /// The count anthem's affected set is therefore "creatures you control" + /// while the entrant enters under the OPPONENT (mirroring + /// `controller_keyed_count_anthem_with_control_theft_board`), which makes + /// `SetToughness` + /// the only entrant-reaching writer and gives the revert-check SetPT-arm + /// granularity rather than whole-kind granularity. + fn tough_count_anthem_with_set_toughness_board() -> GameState { + use crate::types::ability::{ContinuousModification, PtStat, PtValueScope}; + use crate::types::ControllerRef; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 620 + i, &format!("ToughBear{i}"), vec![]); + } + // Layer 7b, controller-agnostic: reaches the opponent's entrant too. + let setter = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::SetToughness { value: 4 }], + ); + let tough_creatures = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::PtComparison { + stat: PtStat::Toughness, + scope: PtValueScope::Current, + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 4 }, + }], + ..Default::default() + }); + // Layer 7c, "creatures you control": deliberately EXCLUDES the + // opponent-controlled entrant. + let count = continuous_static( + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: Some(ControllerRef::You), + ..Default::default() + }), + vec![ContinuousModification::AddDynamicPower { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: tough_creatures, + }, + }, + }], + ); + install_static_enchantment( + &mut state, + 630, + "Toughness Count Anthem", + vec![setter, count], + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn pt_change_entry_escalates_when_population_is_pt_keyed() { + let (normal, escalated, forced) = + flush_entry_and_forced(tough_count_anthem_with_set_toughness_board, |s| { + add_colorless_creature_entry_under(s, 631, PlayerId(1)) + }); + assert!( + escalated, + "a layer-7b toughness set reaching the entrant moves a P/T-keyed \ + count — the entry must escalate to a full re-evaluation" + ); + assert_eq!( + pts_named(&forced, "ToughBear"), + vec![(Some(5), Some(4)); 2], + "full pass counts the entrant once its toughness is set to 4" + ); + assert_eq!( + pts_named(&normal, "ToughBear"), + pts_named(&forced, "ToughBear"), + "escalated entry must derive the same board as full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "P/T-keyed escalation"); + } + + /// (2.3) NAME channel (CR 613.1c + CR 612.8). A layer-3 `SetTextName` + /// reaches the entrant while the anthem's magnitude counts creatures by + /// name. Pre-layer the entrant is "Insect" and matches nothing; + /// post-layer it is a third "Doppelganger" and the pre-existing pair + /// goes 4/4 → 5/5. + fn named_count_anthem_with_name_rewrite_board() -> GameState { + use crate::types::ability::ContinuousModification; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 640 + i, "Doppelganger", vec![]); + } + let rename = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::SetTextName { + name: "Doppelganger".to_string(), + }], + ); + let doppelgangers = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::Named { + name: "Doppelganger".to_string(), + }], + ..Default::default() + }); + let count = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + dynamic_pt_count(doppelgangers), + ); + install_static_enchantment( + &mut state, + 650, + "Doppelganger Count Anthem", + vec![rename, count], + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn name_change_entry_escalates_when_population_is_name_keyed() { + let (normal, escalated, forced) = + flush_entry_and_forced(named_count_anthem_with_name_rewrite_board, |s| { + add_colorless_creature_entry(s, 651) + }); + assert!( + escalated, + "a layer-3 name rewrite reaching the entrant moves a name-keyed \ + count — the entry must escalate to a full re-evaluation" + ); + // The two pre-existing Doppelgangers are the only 2/2 printed bodies; + // the entrant is a 1/1, so it cannot be confused with them. + let bears = |s: &GameState| { + let mut pts: Vec<(Option, Option)> = s + .battlefield + .iter() + .filter_map(|id| s.objects.get(id)) + .filter(|o| o.base_power == Some(2) && o.base_toughness == Some(2)) + .map(|o| (o.power, o.toughness)) + .collect(); + pts.sort(); + pts + }; + assert_eq!( + bears(&forced), + vec![(Some(5), Some(5)); 2], + "full pass counts the renamed entrant — correct CR 613 board" + ); + assert_eq!( + bears(&normal), + bears(&forced), + "escalated entry must derive the same board as full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "name-keyed escalation"); + } + + /// (2.4) NARROWING NEGATIVE. A layer-6 keyword grant reaches the entrant, + /// but nothing live READS abilities: the only population read is an + /// artifact count (CR 613.1d) and both affected filters are plain + /// typelines. `{Abilities, PowerToughness} ∩ {CardTypes} = ∅`, so the + /// entry must stay on the incremental fast path — a board the previous + /// one-sided gate had no way to keep there, since it escalated on any + /// recognized writer plus any population read. + /// + /// Revert direction: make `AddKeyword` write `ALL` and the assertion + /// flips, which is what proves the narrowing is the classifier's doing. + fn artifact_count_anthem_with_keyword_grant_board() -> GameState { + use crate::types::ability::ContinuousModification; + use crate::types::Keyword; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 660 + i, &format!("DisjointBear{i}"), vec![]); + } + // One pre-existing artifact so the counted population is non-empty. + let relic = create_object( + &mut state, + CardId(662), + PlayerId(0), + "Relic".to_string(), + Zone::Battlefield, + ); + { + let o = state.objects.get_mut(&relic).unwrap(); + o.base_card_types.core_types = vec![CoreType::Artifact]; + o.card_types.core_types = vec![CoreType::Artifact]; + } + let grant = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + }], + ); + let artifacts = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + ..Default::default() + }); + let count = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + dynamic_pt_count(artifacts), + ); + install_static_enchantment( + &mut state, + 670, + "Artifact Count With Grant", + vec![grant, count], + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn keyword_grant_entry_stays_incremental_when_population_reads_are_disjoint() { + use crate::types::Keyword; + let (normal, escalated, forced) = + flush_entry_and_forced(artifact_count_anthem_with_keyword_grant_board, |s| { + add_colorless_creature_entry(s, 671) + }); + assert!( + !escalated, + "a keyword grant cannot move an artifact-keyed count — \ + {{Abilities}} ∩ {{CardTypes}} = ∅, so the entry must stay incremental" + ); + // Non-vacuity: the grant really does reach the entrant, so the + // narrowing is the kind relation's doing and not a missed match. + let entrant = normal + .battlefield + .iter() + .filter_map(|id| normal.objects.get(id)) + .find(|o| o.name == "Insect") + .expect("entrant on battlefield"); + assert!( + entrant.has_keyword(&Keyword::Flying), + "the entrant must actually be a recipient of the layer-6 grant" + ); + assert_pt_identical(&normal, &forced, "disjoint-kind non-escalation"); + } + + /// (2.5) AFFECTED-FILTER READ CHANNEL. This board has NO dynamic + /// magnitude and NO static condition — the ONLY name read on it lives in + /// another modification's AFFECTED FILTER. A layer-3 `SetTextName` + /// renames the entering artifact to "Doppelganger", which adds that name + /// to the reference set of the buff's `DifferentNameFrom` filter and + /// therefore REMOVES the pre-existing Doppelganger creature from the + /// buff's affected set (5/5 → 2/2). + /// + /// Pre-layer the entering Treasure does not match the reference filter + /// (which is keyed on the name it does not yet have), so Axis 2a's + /// per-entrant narrowing reports "no perturbation" and only the kind + /// relation can catch this. Revert direction: drop the affected-filter + /// channel from `live_characteristic_reads` and `ReadKinds` becomes + /// empty, so the gate exits at stage 2 and the board goes stale. + fn name_rewrite_with_affected_filter_read_board() -> GameState { + use crate::types::ability::ContinuousModification; + let mut state = setup(); + add_relation_bear(&mut state, 680, "Doppelganger", vec![]); + let rename = continuous_static( + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + ..Default::default() + }), + vec![ContinuousModification::SetTextName { + name: "Doppelganger".to_string(), + }], + ); + // "each artifact you control named Doppelganger" — the entrant only + // joins this set AFTER layer 3 renames it. + let named_artifacts = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + properties: vec![FilterProp::Named { + name: "Doppelganger".to_string(), + }], + ..Default::default() + }); + let buff = continuous_static( + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::DifferentNameFrom { + filter: Box::new(named_artifacts), + }], + ..Default::default() + }), + vec![ + ContinuousModification::AddPower { value: 3 }, + ContinuousModification::AddToughness { value: 3 }, + ], + ); + install_static_enchantment(&mut state, 690, "Different Name Buff", vec![rename, buff]); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn name_rewrite_entry_escalates_through_affected_filter_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(name_rewrite_with_affected_filter_read_board, |s| { + add_artifact_entry(s, 691) + }); + assert!( + escalated, + "a layer-3 rename feeding another static's AFFECTED FILTER must \ + escalate — the affected-filter read channel is unconditional" + ); + let bear = |s: &GameState| { + s.battlefield + .iter() + .filter_map(|id| s.objects.get(id)) + .find(|o| o.base_power == Some(2)) + .map(|o| (o.power, o.toughness)) + .expect("pre-existing Doppelganger on battlefield") + }; + assert_eq!( + bear(&forced), + (Some(2), Some(2)), + "full pass drops the pre-existing Doppelganger out of the buff \ + once the renamed artifact joins the reference set" + ); + assert_eq!( + bear(&normal), + bear(&forced), + "escalated entry must derive the same board as full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "affected-filter read channel"); + } + + /// (2.6) CONDITION READ CHANNEL through the walker's NET-NEW recursion. + /// The buff is gated by `RecipientMatchesFilter` over a keyword filter — + /// a condition whose MEMBERSHIP twin + /// (`static_condition_uses_object_population`) answers `false`, so Axis + /// 2b never looks at it. Only `static_condition_characteristic_reads` + /// recursing into that filter puts `Abilities` into `ReadKinds`, which is + /// what makes the layer-6 grant reaching the entrant intersect. + /// + /// The DISCRIMINATING assertion here is the escalation bit: revert the + /// `SourceMatchesFilter` / `RecipientMatchesFilter` arms to EMPTY and it + /// flips. The identity assertion is the usual under-escalation tripwire, + /// not an independent proof of divergence. + fn condition_keyed_buff_with_keyword_grant_board() -> GameState { + use crate::types::ability::ContinuousModification; + use crate::types::{Keyword, StaticCondition}; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 700 + i, &format!("CondBear{i}"), vec![]); + } + let grant = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::AddKeyword { + keyword: Keyword::Flying, + }], + ); + let mut buff = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ + ContinuousModification::AddPower { value: 3 }, + ContinuousModification::AddToughness { value: 3 }, + ], + ); + buff.condition = Some(StaticCondition::RecipientMatchesFilter { + filter: TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::WithKeyword { + value: Keyword::Flying, + }], + ..Default::default() + }), + }); + install_static_enchantment(&mut state, 710, "Flying-Gated Buff", vec![grant, buff]); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn keyword_grant_entry_escalates_through_condition_filter_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(condition_keyed_buff_with_keyword_grant_board, |s| { + add_colorless_creature_entry(s, 711) + }); + assert!( + escalated, + "a live condition reading keywords through its own filter must put \ + Abilities in ReadKinds, so the layer-6 grant intersects and escalates" + ); + assert_eq!( + pts_named(&forced, "CondBear"), + vec![(Some(5), Some(5)); 2], + "the granted flying satisfies the recipient condition on the full pass" + ); + assert_pt_identical(&normal, &forced, "condition-filter read channel"); + } + + /// (2.7) CONDITION-ADJACENT NARROWING NEGATIVE. `ChangeController` is the + /// writer the PREVIOUS gate recognized (it was one of its three + /// population keys), and it genuinely reaches the entrant here. Every + /// live read on this board is keyed purely on power/toughness — the + /// counted filter and both affected filters use a bare `PtComparison` + /// with no type constraint and no controller scope — so + /// `{Controller} ∩ {PowerToughness} = ∅` and the control theft cannot + /// move anything. The old gate escalated this board; the relation keeps + /// it incremental. + fn pt_keyed_count_anthem_with_control_theft_board() -> GameState { + use crate::types::ability::{ContinuousModification, PtStat, PtValueScope}; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 720 + i, &format!("ScopeBear{i}"), vec![]); + } + // Layer 2: steals every object with power ≤ 1, i.e. the 1/1 entrant. + let thief = continuous_static( + TargetFilter::Typed(TypedFilter { + properties: vec![FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::LE, + value: QuantityExpr::Fixed { value: 1 }, + }], + ..Default::default() + }), + vec![ContinuousModification::ChangeController], + ); + let tough_objects = TargetFilter::Typed(TypedFilter { + properties: vec![FilterProp::PtComparison { + stat: PtStat::Toughness, + scope: PtValueScope::Current, + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 2 }, + }], + ..Default::default() + }); + let count = continuous_static( + tough_objects.clone(), + vec![ContinuousModification::AddDynamicPower { + value: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: tough_objects, + }, + }, + }], + ); + install_static_enchantment( + &mut state, + 730, + "Toughness Count With Theft", + vec![thief, count], + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn control_change_entry_stays_incremental_when_reads_are_pt_only() { + let (normal, escalated, forced) = + flush_entry_and_forced(pt_keyed_count_anthem_with_control_theft_board, |s| { + add_colorless_creature_entry_under(s, 731, PlayerId(1)) + }); + assert!( + !escalated, + "control theft cannot move a purely P/T-keyed population — \ + {{Controller}} ∩ {{PowerToughness}} = ∅, so the entry stays incremental" + ); + // Non-vacuity: the layer-2 writer really does reach the entrant, so + // the old one-sided gate would have escalated this exact board. + let entrant = normal + .battlefield + .iter() + .filter_map(|id| normal.objects.get(id)) + .find(|o| o.name == "Insect") + .expect("entrant on battlefield"); + assert_eq!( + entrant.controller, + PlayerId(0), + "the entrant must actually be a recipient of the layer-2 control change" + ); + assert_pt_identical(&normal, &forced, "controller-vs-P/T non-escalation"); + } + + /// (2.8) CR 613.6 SELF-EXCLUSION CARVE-OUT. One Continuous definition + /// whose modifications WRITE exactly the kind its OWN affected filter + /// READS, and nothing else on the board reads anything: the buff is + /// `AddPower`/`AddToughness` (writes `{PowerToughness}`) over "creatures + /// with power ≤ 1" (reads `{CardTypes, PowerToughness}`). There is no + /// dynamic magnitude and no static condition, so the affected filter is + /// the whole read union. + /// + /// CR 613.6 locks the effect's affected-object set the first time the + /// effect applies and retains it for the rest of the pass, so the buff + /// cannot push the entrant back out of the set it was just admitted to. + /// Its own write is therefore not a read it can move, and the entry must + /// stay incremental. + /// + /// Revert direction: drop the per-modification exclusion and test stage 4 + /// against the whole `ReadKinds` union again — `{PowerToughness}` then + /// intersects its own affected filter's reads and the escalation + /// assertion flips. + fn self_reading_pt_buff_board() -> GameState { + use crate::types::ability::{ContinuousModification, PtStat, PtValueScope}; + let mut state = setup(); + for i in 0..2 { + add_relation_bear(&mut state, 740 + i, &format!("SelfBear{i}"), vec![]); + } + // Layer 7c. Pre-existing 2/2 bears are out of the set (power 2 > 1); + // the 1/1 entrant is in it, and stays in it after the +3/+3 that + // would otherwise disqualify it. + let buff = continuous_static( + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::LE, + value: QuantityExpr::Fixed { value: 1 }, + }], + ..Default::default() + }), + vec![ + ContinuousModification::AddPower { value: 3 }, + ContinuousModification::AddToughness { value: 3 }, + ], + ); + install_static_enchantment(&mut state, 742, "Self-Reading Buff", vec![buff]); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn pt_writer_entry_stays_incremental_when_only_its_own_affected_filter_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(self_reading_pt_buff_board, |s| { + add_colorless_creature_entry(s, 743) + }); + assert!( + !escalated, + "CR 613.6 retains the effect's own affected set, so a modification \ + cannot move the filter that admitted it — the entry stays incremental" + ); + let entrant = |s: &GameState| { + s.battlefield + .iter() + .filter_map(|id| s.objects.get(id)) + .find(|o| o.name == "Insect") + .map(|o| (o.power, o.toughness)) + .expect("entrant on battlefield") + }; + // Non-vacuity: the P/T writer genuinely reaches the entrant, and the + // retained set keeps `AddToughness` applying even though `AddPower` + // already pushed current power past the filter's threshold. + assert_eq!( + entrant(&forced), + (Some(4), Some(4)), + "the entrant must actually be a recipient of the layer-7c buff" + ); + assert_eq!( + pts_named(&forced, "SelfBear"), + vec![(Some(2), Some(2)); 2], + "pre-existing 2/2 bears never satisfy the power ≤ 1 filter" + ); + assert_eq!( + entrant(&normal), + entrant(&forced), + "the incremental entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "CR 613.6 self-exclusion carve-out"); + } + + // ------------------------------------------------------------------ + // RESOLUTION-CREATED continuous effects (CR 611.2b + CR 611.2c). + // + // A `TransientContinuousEffect` is the third `ActiveContinuousEffect` + // producer, alongside printed statics and granted-inner statics. It is + // the only one whose affected set is FROZEN (CR 611.2c) while its two + // gates stay LIVE, so it is the only one where the affected-filter + // channel reports EMPTY and a gate is the sole live read. Those gates + // are the "for as long as" DURATION (CR 611.2b) and the retained + // CONDITION, which is the source definition's own CR 611.3a gate + // carried along; `transient_effect_is_live` consults exactly that pair. + // Every board below builds its effect through the single construction + // authority — see `install_gated_transient` for which production path + // produces this shape and which does NOT. + // ------------------------------------------------------------------ + + /// Install a resolution-created continuous effect that RETAINS a gate, + /// through the single construction authority + /// (`GameState::add_transient_continuous_effect`), one effect per + /// recipient. + /// + /// CR 611.2c: the affected set is already frozen to `SpecificObject` — + /// a filter that reads NO layer-writable characteristic — which is what + /// every transient looks like once its effect has begun. The gate rides + /// alongside and stays live: a `Duration::ForAsLongAs` re-evaluates per + /// pass because CR 611.2b makes the effect last exactly as long as its + /// stated condition holds, and a retained `condition` re-evaluates + /// because it is the source `StaticDefinition`'s own CR 611.3a gate. + /// That asymmetry (frozen set, live gate) is what these boards + /// exercise. + /// + /// REACHABILITY: `Effect::GenericEffect` is NOT the producer of the + /// retained-`condition` shape. Per CR 608.2h + CR 611.2d its resolver + /// determines an in-effect "if " exactly once, at + /// resolution, and installs the transient with `condition: None` + /// (`effects/effect.rs::resolve`). A conditioned transient comes from + /// riders that hand a `StaticDefinition`'s condition straight to the + /// constructor — `effects/counter.rs::apply_source_static` (the + /// `CounterSourceRider::LosesAbilities` rider) is the live example, and + /// no shipped card gives that rider a condition yet, so the + /// `condition`-gated boards below are preventive. The + /// `Duration::ForAsLongAs` shape needs no such caveat: it is what the + /// parser emits for any "for as long as …" clause it can read + /// (`parser/oracle_nom/duration.rs`), and gain-control, phasing and + /// copy effects install it today. + fn install_gated_transient( + state: &mut GameState, + source: ObjectId, + recipients: &[ObjectId], + mods: Vec, + duration: Duration, + condition: Option, + ) { + for &id in recipients { + state.add_transient_continuous_effect( + source, + PlayerId(0), + duration.clone(), + TargetFilter::SpecificObject { id }, + mods.clone(), + condition.clone(), + ); + } + } + + /// (3.1) TRANSIENT CONDITION READ CHANNEL. The only live read of card + /// types on this board lives in the CONDITION of a resolution-created + /// continuous effect whose affected set is already frozen to + /// `SpecificObject` (CR 611.2c), i.e. to an EMPTY-read filter. A + /// separate printed layer-4 `AddType{Land}` (CR 613.1d) reaches the + /// entrant, so the Land population that condition counts moves for + /// every pre-existing recipient. + /// + /// CR 611.2c freezes the affected SET and nothing else; the retained + /// gate is the source definition's own CR 611.3a condition, so it keeps + /// re-evaluating on every pass, here per recipient + /// (`FilterProp::Another`). Pre-entry each 2/2 recipient sees exactly + /// 1 OTHER Land so `GE 2` is OFF; post-entry it sees 2 and turns ON + /// (3/3). + /// + /// DISCRIMINATING because the transient is the ONLY condition on the + /// board: drop the `e.condition` channel from `live_characteristic_reads` + /// and `ReadKinds` loses CardTypes entirely. Stage 4 then exempts the + /// layer-4 writer under CR 613.6 — the only other CardTypes read is its + /// OWN affected filter — the entry stays on the incremental path, and + /// the pre-existing recipients keep a stale 2/2. The entry-perturbation + /// probe cannot rescue it: the entrant is not a Land until layer 4 has + /// run, so `entered_object_perturbs_static_condition` sees no + /// perturbation. The recipient-context gate is what keeps this board on + /// the `e.condition` channel — `transient_source_level_condition_read_board` + /// covers the source-level twin, which that channel never sees. + fn transient_condition_read_board() -> GameState { + use crate::types::ability::{ContinuousModification, StaticCondition}; + let mut state = setup(); + let mut bears = Vec::new(); + for i in 0..2 { + bears.push(add_relation_bear( + &mut state, + 760 + i, + &format!("TransientBear{i}"), + vec![], + )); + } + // CR 613.1d: printed layer-4 type-changer, unconditional. Every + // creature is also a Land, so it reaches the entrant and MOVES the + // counted population. + let to_land = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::AddType { + core_type: CoreType::Land, + }], + ); + install_static_enchantment(&mut state, 762, "Land Conversion", vec![to_land]); + let source = install_static_enchantment(&mut state, 763, "Other-Lands Grant", vec![]); + install_gated_transient( + &mut state, + source, + &bears, + vec![ + ContinuousModification::AddPower { value: 1 }, + ContinuousModification::AddToughness { value: 1 }, + ], + Duration::UntilEndOfTurn, + // Recipient-relative: "two or more OTHER Lands". + Some(StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Land], + properties: vec![FilterProp::Another], + ..Default::default() + }), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 2 }, + }), + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn type_rewrite_entry_escalates_through_transient_condition_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(transient_condition_read_board, |s| { + add_colorless_creature_entry(s, 764) + }); + assert!( + escalated, + "a resolution-created effect's retained condition is a LIVE read \ + (CR 611.3a), so a layer-4 type rewrite reaching the entrant moves \ + the population it counts — the entry must escalate" + ); + // Non-vacuity: CR 611.2c really did freeze the affected set, so the + // affected-filter channel reports EMPTY and the condition channel is + // the only thing that can put CardTypes in ReadKinds. + assert!( + !forced.transient_continuous_effects.is_empty() + && forced.transient_continuous_effects.iter().all(|tce| { + matches!(tce.affected, TargetFilter::SpecificObject { .. }) + && tce.condition.is_some() + }), + "the grant must have resolved into SpecificObject-bound transients \ + that still carry their condition" + ); + // Non-vacuity: the gate is genuinely OFF before the entry, so the 3/3 + // below is the entrant's doing and not an already-buffed board. + let mut pre = transient_condition_read_board(); + flush_layers(&mut pre); + assert_eq!( + pts_named(&pre, "TransientBear"), + vec![(Some(2), Some(2)); 2], + "pre-entry each recipient sees only 1 OTHER Land, so GE 2 is OFF" + ); + assert_eq!( + pts_named(&forced, "TransientBear"), + vec![(Some(3), Some(3)); 2], + "the full pass counts the entrant once layer 4 makes it a Land, so \ + each recipient sees 2 OTHER Lands and the gate turns ON" + ); + assert_eq!( + pts_named(&normal, "TransientBear"), + pts_named(&forced, "TransientBear"), + "the escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "transient condition read channel"); + } + + /// (3.2) TRANSIENT SOURCE-LEVEL GATE. Same frozen-set/live-gate + /// asymmetry, but the gate is a plain SOURCE-LEVEL presence check (the + /// source definition's own CR 611.3a condition, carried onto the + /// transient) instead of a recipient-context count, and NOTHING on the + /// board writes the kinds it reads. That makes the entry-perturbation + /// probe the only disjunct that can catch it: an opponent's creature + /// entering flips `IsPresent{creature an opponent controls}` from OFF + /// to ON, and every recipient frozen into the effect's set (CR 611.2c) + /// goes 2/2 → 5/5. + /// + /// DISCRIMINATING: drop the transient walk from + /// `any_active_static_condition_perturbed_by_entry` and the + /// printed-static walk sees no condition at all, the kind relation + /// exits at stage 3 (`{PowerToughness} ∩ {CardTypes, Controller} = ∅`), + /// and the recipients stay stale at 2/2. + fn transient_source_level_gate_board() -> GameState { + use crate::types::ability::{ContinuousModification, StaticCondition}; + use crate::types::ControllerRef; + let mut state = setup(); + let mut bears = Vec::new(); + for i in 0..2 { + bears.push(add_relation_bear( + &mut state, + 770 + i, + &format!("GatedBear{i}"), + vec![], + )); + } + let source = + install_static_enchantment(&mut state, 772, "Opponent-Gated Grant", vec![]); + install_gated_transient( + &mut state, + source, + &bears, + vec![ + ContinuousModification::AddPower { value: 3 }, + ContinuousModification::AddToughness { value: 3 }, + ], + Duration::UntilEndOfTurn, + // CR 109.5: a resolved effect RETAINS its controller, so "an + // opponent" is read against P0. OFF on this board. + Some(StaticCondition::IsPresent { + filter: Some(TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: Some(ControllerRef::Opponent), + ..Default::default() + })), + }), + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn opponent_entry_escalates_through_transient_source_level_gate() { + let (normal, escalated, forced) = + flush_entry_and_forced(transient_source_level_gate_board, |s| { + add_colorless_creature_entry_under(s, 773, PlayerId(1)) + }); + assert!( + escalated, + "CR 611.2c freezes a resolved effect's affected SET, not its gate — \ + an entry that flips a transient's source-level presence gate must \ + escalate or every frozen recipient keeps a stale board" + ); + // Non-vacuity: the gate is genuinely OFF before the entry. + let mut pre = transient_source_level_gate_board(); + flush_layers(&mut pre); + assert_eq!( + pts_named(&pre, "GatedBear"), + vec![(Some(2), Some(2)); 2], + "pre-entry no opponent controls a creature, so the gate is OFF" + ); + assert_eq!( + pts_named(&forced, "GatedBear"), + vec![(Some(5), Some(5)); 2], + "the opponent's entrant turns the gate ON for every frozen recipient" + ); + assert_eq!( + pts_named(&normal, "GatedBear"), + pts_named(&forced, "GatedBear"), + "the escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "transient source-level gate"); + } + + /// The name a layer-1 override (CR 707.9b) stamps onto every creature on + /// the (3.3)/(3.4) boards. + const OVERRIDDEN_NAME: &str = "Cloned Bear"; + + /// "Three or more permanents named `OVERRIDDEN_NAME`", counted + /// board-wide. Same shape as (3.1)'s gate minus the recipient context: + /// no `FilterProp::Another`, so `condition_uses_recipient_context` is + /// false and every gather strips it off the effect it pushes. + fn overridden_name_count_at_least(count: i32) -> crate::types::ability::StaticCondition { + crate::types::ability::StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Named { + name: OVERRIDDEN_NAME.to_string(), + }, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: count }, + } + } + + /// Two 2/2 recipients, a printed LAYER-1 `SetName` override (CR 707.9b) + /// over creatures, and one +1/+1 transient per recipient gated on + /// `install_gate`. + /// + /// WHY LAYER 1 and not the layer-4 rewrite (3.1) uses: a source-level + /// condition and a `ForAsLongAs` duration are both evaluated inside + /// `gather_transient_continuous_effects`, and `evaluate_layers` gathers + /// at Step 3 — after layer 1 has been applied and before layers 2-7. + /// Layer 1 is therefore the ONLY layer whose writes such a gate can see + /// within one pass. (A retained recipient-context condition is instead + /// re-checked at APPLY time, which is why (3.1) can use layer 4.) + /// `prepare_incremental_flush` gathers with NO layer applied at all, so + /// the entrant is still printed-named there — that divergence is exactly + /// the staleness these boards catch. + /// + /// Nothing else on the board is a creature, so the overridden-name + /// population is exactly the creature count: 2 before the entry, 3 + /// after, which moves a `GE 3` gate from OFF to ON. + fn transient_name_count_gate_board( + install_gate: impl Fn(&mut GameState, ObjectId, &[ObjectId]), + ) -> GameState { + use crate::types::ability::ContinuousModification; + let mut state = setup(); + let mut bears = Vec::new(); + for i in 0..2 { + bears.push(add_relation_bear( + &mut state, + 780 + i, + &format!("NameBear{i}"), + vec![], + )); + } + // CR 707.9b: a layer-1 copiable-value name override, which MOVES the + // counted population by reaching the entrant. + let rename = continuous_static( + TargetFilter::Typed(TypedFilter::creature()), + vec![ContinuousModification::SetName { + name: OVERRIDDEN_NAME.to_string(), + }], + ); + install_static_enchantment(&mut state, 782, "Mass Renaming", vec![rename]); + let source = install_static_enchantment(&mut state, 783, "Name-Gated Grant", vec![]); + install_gate(&mut state, source, &bears); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + /// (3.3) TRANSIENT SOURCE-LEVEL CONDITION, READ CHANNEL. The twin of + /// (3.1) with the recipient context removed. A source-level condition + /// never reaches `ActiveContinuousEffect::condition`: + /// `gather_transient_continuous_effects` strips it (only a + /// recipient-context condition is retained), and while the gate is OFF + /// the effect is not gathered at all. So the ONLY way NameText enters + /// `ReadKinds` is the walk over `state.transient_continuous_effects`. + /// + /// DISCRIMINATING: drop that walk and `ReadKinds` holds only the + /// CardTypes its affected filters read, which is disjoint from the + /// layer-1 `SetName` writer's `{NameText}` — the relation exits at + /// stage 3, the entry stays incremental, and the recipients keep a + /// stale 2/2 while a full pass says 3/3. The perturbation probe cannot + /// rescue it either: the entrant still carries its printed name when + /// the probe runs, so it does not perturb the overridden-name count. + fn transient_source_level_condition_read_board() -> GameState { + use crate::types::ability::ContinuousModification; + transient_name_count_gate_board(|state, source, bears| { + install_gated_transient( + state, + source, + bears, + vec![ + ContinuousModification::AddPower { value: 1 }, + ContinuousModification::AddToughness { value: 1 }, + ], + Duration::UntilEndOfTurn, + Some(overridden_name_count_at_least(3)), + ) + }) + } + + #[test] + fn name_rewrite_entry_escalates_through_transient_source_level_condition_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(transient_source_level_condition_read_board, |s| { + add_colorless_creature_entry(s, 784) + }); + assert!( + escalated, + "a source-level gate on a resolution-created effect is stripped from \ + the gathered effect, so only the transient walk can put NameText in \ + ReadKinds — a layer-1 name override reaching the entrant must escalate" + ); + // Non-vacuity, fixture side: the installed gate really is the + // board-wide count — no `FilterProp::Another`, nothing else + // recipient-relative — so it is source-level. + assert!( + !forced.transient_continuous_effects.is_empty() + && forced.transient_continuous_effects.iter().all(|tce| { + matches!(tce.affected, TargetFilter::SpecificObject { .. }) + && tce.condition.as_ref() == Some(&overridden_name_count_at_least(3)) + }), + "the fixture must install SpecificObject-bound transients whose gate is \ + source-level, or the `e.condition` channel would cover this board" + ); + // Non-vacuity, GATHERED side: asserting the fixture only proves what + // was installed. Run the real gather and confirm the condition is + // gone from every effect it produces — that strip is the whole + // premise of this test, so it is asserted, not inferred. + let mut gathered = Vec::new(); + crate::game::layers::gather_transient_continuous_effects(&forced, &mut gathered); + assert!( + !gathered.is_empty() && gathered.iter().all(|e| e.condition.is_none()), + "the gather must strip this source-level condition; if it retained it, \ + `e.condition` would cover the board and the transient walk would be \ + untested here" + ); + let mut pre = transient_source_level_condition_read_board(); + flush_layers(&mut pre); + assert_eq!( + pts_base_named(&pre, "NameBear"), + vec![(Some(2), Some(2)); 2], + "pre-entry only 2 permanents carry the overridden name, so GE 3 is OFF" + ); + assert_eq!( + pts_base_named(&forced, "NameBear"), + vec![(Some(3), Some(3)); 2], + "layer 1 renames the entrant too, making it the third — the gate turns ON" + ); + assert_eq!( + pts_base_named(&normal, "NameBear"), + pts_base_named(&forced, "NameBear"), + "the escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "transient source-level condition reads"); + } + + /// (3.4) `ForAsLongAs` DURATION, READ CHANNEL. Identical board to (3.3) + /// with the gate moved from `tce.condition` into + /// `Duration::ForAsLongAs` (CR 611.2b — the effect lasts exactly as + /// long as its stated condition holds). `transient_effect_is_live` + /// evaluates it in the same gather, and no gather ever copies a + /// duration's condition onto an `ActiveContinuousEffect`, so this gate + /// is invisible to every channel except the transient walk. + /// + /// DISCRIMINATING: drop `transient_duration_condition` from + /// `transient_gate_conditions` and `ReadKinds` loses NameText exactly + /// as in (3.3) — recipients keep a stale 2/2. + fn transient_duration_gate_read_board() -> GameState { + use crate::types::ability::ContinuousModification; + transient_name_count_gate_board(|state, source, bears| { + install_gated_transient( + state, + source, + bears, + vec![ + ContinuousModification::AddPower { value: 1 }, + ContinuousModification::AddToughness { value: 1 }, + ], + Duration::ForAsLongAs { + condition: overridden_name_count_at_least(3), + }, + None, + ) + }) + } + + #[test] + fn name_rewrite_entry_escalates_through_transient_duration_gate_reads() { + let (normal, escalated, forced) = + flush_entry_and_forced(transient_duration_gate_read_board, |s| { + add_colorless_creature_entry(s, 785) + }); + assert!( + escalated, + "CR 611.2b makes a `for as long as` duration a live gate, so the kinds \ + it reads are live reads — a layer-1 name override reaching the entrant \ + must escalate" + ); + // Non-vacuity: the gate lives in the DURATION, not in `condition`, + // so no `tce.condition` channel could have covered this board. + assert!( + !forced.transient_continuous_effects.is_empty() + && forced.transient_continuous_effects.iter().all(|tce| { + tce.condition.is_none() + && matches!(tce.duration, Duration::ForAsLongAs { .. }) + }), + "the fixture must gate purely through `Duration::ForAsLongAs`" + ); + let mut pre = transient_duration_gate_read_board(); + flush_layers(&mut pre); + assert_eq!( + pts_base_named(&pre, "NameBear"), + vec![(Some(2), Some(2)); 2], + "pre-entry only 2 permanents carry the overridden name, so the \ + duration has not started" + ); + assert_eq!( + pts_base_named(&forced, "NameBear"), + vec![(Some(3), Some(3)); 2], + "layer 1 renames the entrant too, making it the third — the duration holds" + ); + assert_eq!( + pts_base_named(&normal, "NameBear"), + pts_base_named(&forced, "NameBear"), + "the escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "transient duration gate reads"); + } + + /// (3.5) `ForAsLongAs` DURATION, PERTURBATION-PROBE CHANNEL. The twin + /// of (3.2) with the gate moved into the duration: Master Thief's "for + /// as long as you control this creature" shape, inverted to an + /// opponent-presence check so an entry can start it. NOTHING on this + /// board writes the kinds the gate reads, so the read union cannot see + /// the flip — while the duration is unmet the effect is not gathered at + /// all and `all_writes` is empty, which exits the kind relation at + /// stage 1. + /// + /// DISCRIMINATING: drop `transient_duration_condition` from + /// `transient_gate_conditions` and the probe's transient arm sees only + /// `tce.condition`, which is `None` here — no disjunct fires, the entry + /// stays incremental, and the frozen recipients keep a stale 2/2 while + /// a full pass says 5/5. + fn transient_duration_gate_probe_board() -> GameState { + use crate::types::ability::{ContinuousModification, StaticCondition}; + use crate::types::ControllerRef; + let mut state = setup(); + let mut bears = Vec::new(); + for i in 0..2 { + bears.push(add_relation_bear( + &mut state, + 790 + i, + &format!("DurationBear{i}"), + vec![], + )); + } + let source = + install_static_enchantment(&mut state, 792, "Opponent-Gated Duration", vec![]); + install_gated_transient( + &mut state, + source, + &bears, + vec![ + ContinuousModification::AddPower { value: 3 }, + ContinuousModification::AddToughness { value: 3 }, + ], + // CR 611.2b + CR 109.5: the duration is re-read every pass and + // "an opponent" stays bound to the resolver, P0. + Duration::ForAsLongAs { + condition: StaticCondition::IsPresent { + filter: Some(TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: Some(ControllerRef::Opponent), + ..Default::default() + })), + }, + }, + None, + ); + state.layers_dirty = crate::types::game_state::LayersDirty::Full; + state + } + + #[test] + fn opponent_entry_escalates_through_transient_duration_gate() { + let (normal, escalated, forced) = + flush_entry_and_forced(transient_duration_gate_probe_board, |s| { + add_colorless_creature_entry_under(s, 793, PlayerId(1)) + }); + assert!( + escalated, + "CR 611.2c freezes a resolved effect's affected SET, not its duration — \ + an entry that starts a `for as long as` duration must escalate or every \ + frozen recipient keeps a stale board" + ); + // Non-vacuity: the gate lives in the DURATION only. + assert!( + !forced.transient_continuous_effects.is_empty() + && forced.transient_continuous_effects.iter().all(|tce| { + tce.condition.is_none() + && matches!(tce.duration, Duration::ForAsLongAs { .. }) + }), + "the fixture must gate purely through `Duration::ForAsLongAs`" + ); + let mut pre = transient_duration_gate_probe_board(); + flush_layers(&mut pre); + assert_eq!( + pts_named(&pre, "DurationBear"), + vec![(Some(2), Some(2)); 2], + "pre-entry no opponent controls a creature, so the duration never started" + ); + assert_eq!( + pts_named(&forced, "DurationBear"), + vec![(Some(5), Some(5)); 2], + "the opponent's entrant starts the duration for every frozen recipient" + ); + assert_eq!( + pts_named(&normal, "DurationBear"), + pts_named(&forced, "DurationBear"), + "the escalated entry must derive the same board as a full re-evaluation" + ); + assert_pt_identical(&normal, &forced, "transient duration gate probe"); + } + /// Assert every battlefield object's computed power/toughness/loyalty and /// keyword set are identical across two states. fn assert_pt_identical(a: &GameState, b: &GameState, label: &str) { diff --git a/crates/engine/src/game/static_abilities.rs b/crates/engine/src/game/static_abilities.rs index 7edbffa73b..cac61afe3c 100644 --- a/crates/engine/src/game/static_abilities.rs +++ b/crates/engine/src/game/static_abilities.rs @@ -9,7 +9,7 @@ use crate::game::functioning_abilities::{ use crate::game::game_object::GameObject; use crate::game::layers::{evaluate_condition, evaluate_condition_with_recipient}; use crate::types::ability::{ - ContinuousModification, ControllerRef, Duration, StaticDefinition, TargetFilter, TypedFilter, + ContinuousModification, ControllerRef, StaticDefinition, TargetFilter, TypedFilter, }; use crate::types::game_state::GameState; use crate::types::identifiers::ObjectId; @@ -846,15 +846,13 @@ pub(crate) fn transient_grants_static_mode_to_player( if affected_id != player_id { continue; } - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) + { + continue; } let grants_mode = tce.modifications.iter().any(|m| { matches!(m, ContinuousModification::AddStaticMode { mode: m_mode } if m_mode == mode) @@ -894,15 +892,13 @@ pub(crate) fn transient_grants_static_mode_to_object( ) { continue; } - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) + { + continue; } let grants_mode = tce.modifications.iter().any(|m| { matches!(m, ContinuousModification::AddStaticMode { mode: m_mode } if m_mode == mode) @@ -932,22 +928,12 @@ pub(crate) fn transient_grants_static_mode_to_object( /// deliberately skips); (2) any filter-scoped transient grant; and (3) a printed /// static (parity with the `CantUntap` intrinsic path, future-proofing). pub(crate) fn object_has_active_cant_phase_in(state: &GameState, object_id: ObjectId) -> bool { - let condition_holds = |duration: &Duration, - condition: &Option, - controller: PlayerId, - source_id: ObjectId| - -> bool { - if let Duration::ForAsLongAs { condition } = duration { - if !evaluate_condition(state, condition, controller, source_id) { - return false; - } - } - if let Some(condition) = condition { - if !evaluate_condition(state, condition, controller, source_id) { - return false; - } - } - true + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + let condition_holds = |tce: &crate::types::game_state::TransientContinuousEffect| { + crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) }; // (1) SpecificObject-pinned transient grant — the Pandorica lock. @@ -961,7 +947,7 @@ pub(crate) fn object_has_active_cant_phase_in(state: &GameState, object_id: Obje } ) }) - && condition_holds(&tce.duration, &tce.condition, tce.controller, tce.source_id) + && condition_holds(tce) }); if pinned { return true; @@ -1479,15 +1465,13 @@ pub fn player_has_protection_from_everything(state: &GameState, player_id: Playe continue; } // CR 611.2b: ForAsLongAs durations re-evaluate their condition each cycle. - if let crate::types::ability::Duration::ForAsLongAs { ref condition } = tce.duration { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) + { + continue; } let grants_everything = tce.modifications.iter().any(|m| { matches!( @@ -1805,15 +1789,13 @@ fn transient_grants_other_static_to_context( continue; } // CR 611.2b: ForAsLongAs durations re-evaluate their condition each cycle. - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) + { + continue; } let grants_named_other = tce.modifications.iter().any(|m| { matches!( @@ -2099,15 +2081,13 @@ fn transient_additional_land_drops(state: &GameState, player: PlayerId) -> u8 { continue; } // CR 611.2b: ForAsLongAs durations re-evaluate their condition each cycle. - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !crate::game::layers::transient_gate_conditions(tce) + .all(|condition| evaluate_condition(state, condition, tce.controller, tce.source_id)) + { + continue; } for m in &tce.modifications { if let ContinuousModification::AddStaticMode { mode } = m { diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index 6f53ec78aa..20b4e70f5d 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -716,7 +716,7 @@ fn scan_step_end_mana_handlers( state: &GameState, player_id: PlayerId, ) -> Vec { - use crate::types::ability::{ContinuousModification, Duration, TargetFilter}; + use crate::types::ability::{ContinuousModification, TargetFilter}; use crate::types::game_state::StepEndManaScanEntry; let context = super::static_abilities::StaticCheckContext { @@ -763,15 +763,13 @@ fn scan_step_end_mana_handlers( if affected_id != player_id { continue; } - if let Duration::ForAsLongAs { ref condition } = tce.duration { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(ref condition) = tce.condition { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are. + if !super::layers::transient_gate_conditions(tce).all(|condition| { + super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) + }) { + continue; } for modification in &tce.modifications { if let ContinuousModification::AddStaticMode { diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index faed8a498e..453acfd137 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -1698,7 +1698,7 @@ fn viewer_may_look_at_face_down( obj_id: ObjectId, can_view_private_for_player: &impl Fn(PlayerId) -> bool, ) -> bool { - use crate::types::ability::{ContinuousModification, Duration}; + use crate::types::ability::ContinuousModification; use crate::types::statics::{StaticMode, StaticModeKind}; // CR 708.5: O(1) presence gate covers ONLY the battlefield-static authority. The // duration-bound `transient_continuous_effects` scan below is a separate authority @@ -1741,18 +1741,14 @@ fn viewer_may_look_at_face_down( if !grants_look { continue; } - // Honor the same duration/condition gates the static-mode TCE queries in - // `static_abilities.rs` apply (a `ForAsLongAs` duration or explicit - // `condition` must still hold this look). - if let Duration::ForAsLongAs { condition } = &tce.duration { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } - } - if let Some(condition) = &tce.condition { - if !super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) { - continue; - } + // CR 611.2b + CR 611.3a: every gate of a resolution-created effect must + // hold for it to apply; `transient_gate_conditions` is the authority over + // which those are, shared with the static-mode TCE queries in + // `static_abilities.rs`. + if !super::layers::transient_gate_conditions(tce).all(|condition| { + super::layers::evaluate_condition(state, condition, tce.controller, tce.source_id) + }) { + continue; } // CR 608.2c: "you" is latched to the player who controlled the ability at // resolution (the stored `tce.controller`), NOT the source's current diff --git a/crates/engine/tests/integration/ashaya_nontoken_lands.rs b/crates/engine/tests/integration/ashaya_nontoken_lands.rs index a747e159e9..086b211e33 100644 --- a/crates/engine/tests/integration/ashaya_nontoken_lands.rs +++ b/crates/engine/tests/integration/ashaya_nontoken_lands.rs @@ -162,3 +162,41 @@ fn ashaya_creature_etb_triggers_landfall() { outcome.assert_hand_drawn(P0, 1); } + +/// CR 613.1 + CR 613.1d + CR 613.4a: Ashaya's own CDA counts a population its +/// own layer-4 effect feeds. Layers apply in order (CR 613.1), so "power and +/// toughness equal to the number of lands you control" (layer 7a, CR 613.4a) is +/// computed over a board on which "nontoken creatures you control are Forest +/// lands" (layer 4, CR 613.1d) has already run — an entering nontoken creature +/// raises Ashaya's P/T even though nothing that entered was printed as a land. +/// +/// Regression: found by differential verification against a full re-evaluation +/// during development. The entry- +/// incremental flush escalation gate probed the ENTERING object for membership +/// in "lands you control" using its pre-layer characteristics — a creature, not +/// a land — concluded the count was unperturbed, took the incremental arm, and +/// left Ashaya's P/T stale where a full re-evaluation raises it. +#[test] +fn ashaya_power_counts_a_creature_that_its_own_static_turns_into_a_land() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let ashaya_id = scenario + .add_creature_from_oracle(P0, "Ashaya, Soul of the Wild", 0, 0, ASHAYA) + .id(); + let entering_creature = scenario + .add_creature_to_hand(P0, "Grizzly Bears", 2, 2) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + + runner.cast(entering_creature).resolve(); + + let after = &runner.state().objects[&ashaya_id]; + assert_eq!( + (after.power, after.toughness), + (Some(2), Some(2)), + "both Ashaya and the entering Bears are Forest lands, so the CDA counts 2" + ); +} diff --git a/crates/engine/tests/integration/life_and_limb_sylvan_advocate.rs b/crates/engine/tests/integration/life_and_limb_sylvan_advocate.rs new file mode 100644 index 0000000000..35b81ef86e --- /dev/null +++ b/crates/engine/tests/integration/life_and_limb_sylvan_advocate.rs @@ -0,0 +1,69 @@ +//! Condition-channel twin of the Ashaya entry-flush regression +//! (`ashaya_nontoken_lands.rs`): a static's enabling CONDITION that counts a +//! population must be answered against the post-layer board when the entry +//! escalation gate decides whether an entering object perturbs it. +//! +//! Life and Limb makes all Saprolings Forest lands (layer 4, CR 613.1d); +//! Sylvan Advocate's +2/+2 is gated on "you control six or more lands" +//! (CR 611.3a enabling condition). An entering Saproling is a creature — not a +//! land — at gate time, so a pre-layer probe of "lands you control" would report +//! the count unperturbed even though the post-layer board crosses six. +//! +//! This test pins the CR-correct end-to-end outcome whichever flush arm the +//! board takes. It is deliberately NOT the discriminating test for the +//! escalation gate's condition channel — that is the synthetic fixture named in +//! the per-test comment below, which is constructed to take the incremental +//! path. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; + +const LIFE_AND_LIMB: &str = "All Forests and all Saprolings are 1/1 green \ +Saproling creatures and Forest lands in addition to their other types."; +const SYLVAN_ADVOCATE: &str = "Vigilance\nAs long as you control six or more \ +lands, this creature and land creatures you control get +2/+2."; + +/// CR 611.3a + CR 613.1 + CR 613.1d: with five lands, Life and Limb, and +/// Sylvan Advocate on the battlefield, a Saproling entering becomes the sixth +/// land during the same layer pass, so the Advocate's condition turns on and +/// the Advocate — a PRE-EXISTING recipient — must end the pass at 4/5, not a +/// stale 2/3. +/// +/// This asserts the CR-correct end-to-end outcome and nothing about WHICH flush +/// arm produced it: the assertion holds under either arm, and deliberately does +/// not encode a claim about the arm, because such a claim would be prose that no +/// assertion here can keep honest. The discriminating test for the escalation +/// gate's condition channel is the synthetic +/// `condition_gated_anthem_entry_escalates_when_entrant_types_rewritten` +/// fixture (stack.rs entry-flush escalation tests), which asserts escalation +/// directly. +#[test] +fn sylvan_advocate_condition_counts_a_saproling_that_life_and_limb_turns_into_a_land() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let advocate_id = scenario + .add_creature_from_oracle(P0, "Sylvan Advocate", 2, 3, SYLVAN_ADVOCATE) + .id(); + scenario.add_enchantment_from_oracle(P0, "Life and Limb", LIFE_AND_LIMB); + for i in 0..5 { + scenario.add_land_from_oracle(P0, &format!("Quiet Wastes {i}"), ""); + } + let entering = scenario + .add_creature_to_hand(P0, "Saproling Straggler", 1, 1) + .with_subtypes(vec!["Saproling"]) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + runner.cast(entering).resolve(); + + let advocate = &runner.state().objects[&advocate_id]; + assert_eq!( + (advocate.power, advocate.toughness), + (Some(4), Some(5)), + "the entering Saproling is a Forest land post-layer, lands reach six, \ + and the Advocate's own +2/+2 applies to itself" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 18d62e8b0f..cc54a83739 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -714,6 +714,7 @@ mod lathiel_end_step_counters_repro; mod leeching_sliver; mod leyline_taps_for_mana_repro; mod lictor_opponent_entered_this_turn; +mod life_and_limb_sylvan_advocate; mod lightning_dart_disjunctive_color_instead; mod liliana_dreadhorde_multi_dies; mod liliana_waker_cross_scope_decline;