diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index f53a282b5c..87125c6421 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -2423,10 +2423,12 @@ fn fire_time_conditions_read_growing_class_scoped( // does not imply unreachability. // // Fail closed on ANY `Some(..)`, never on an enumeration of the two - // widening variants. `PlayerFilter` (`types/ability.rs`) has 25 - // variants; enumerating would make THIS site assert that the other 23 - // leave a foreign ability unreachable — a claim nothing forces anyone - // to re-verify when variant 26 lands. `is_none()` asserts nothing about + // widening variants. `PlayerFilter` (`types/ability.rs`) carries + // dozens of variants and keeps growing; enumerating would make THIS + // site assert that every OTHER variant leaves a foreign ability + // unreachable — a claim nothing forces anyone to re-verify when the + // next variant lands. (Deliberately no count here: a hardcoded + // number goes stale silently.) `is_none()` asserts nothing about // any variant: it keys on CR 602.2's own predicate, whether the object // says otherwise AT ALL. Note `player_may_begin_activating`'s // `Some(_) => player == source_controller` catch-all (`casting.rs`) diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 0b29e0b2ba..08f79eeb71 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2183,6 +2183,9 @@ fn legacy_player_filter(x: &PlayerFilter) -> bool { | PlayerFilter::PerformedActionThisWay { .. } | PlayerFilter::OwnersOfCardsExiledBySource | PlayerFilter::VotedFor { .. } + // Per-resolution chain ledger read, like `ZoneChangedThisWay` and the + // `TrackedSetSize` quantity refs — not one of the retained legacy refs. + | PlayerFilter::TrackedSetPossessor { .. } | PlayerFilter::ChosenPlayer { .. } => false, } } @@ -6497,6 +6500,21 @@ fn rw_player_filter(x: &PlayerFilter) -> RwProfile { // like `ControllerRef::EnchantedPlayer`) and reads this-combat attack // declarations against it ⇒ member-bound (refuse batch-T1). PlayerFilter::OpponentAttackingEnchantedPlayer => member_bound_read(), + // CR 603.10a + CR 608.2h: reads the per-resolution tracked object set and + // its cause stamps — a look-back referent keyed on specific members (and + // their LKI) ⇒ member-bound, refuse batch-T1. The inner filter is + // additionally evaluated against board set membership for members still + // on the battlefield, exactly like `ControlsCount`. + PlayerFilter::TrackedSetPossessor { + filter, + relation: _, + possession: _, + caused_by: _, + } => { + let mut p = board_membership_read(filter); + p.merge(member_bound_read()); + p + } PlayerFilter::Controller | PlayerFilter::Opponent | PlayerFilter::DefendingPlayer diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index cc3bb26c15..f21bcb0c82 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -3960,6 +3960,26 @@ fn scan_player_filter(x: &PlayerFilter, mode: ScanMode) -> Axes { sibling: false, projected: false, }, + // CR 603.3b + CR 608.2c: the membership set is published by a PRECEDING + // SIBLING effect in the same chain, and the per-member filter reads live + // board state for members still on the battlefield — both are + // sibling-mutable. Per ADD-1 a newly-added filter site is classified + // `LiveBoardCensus` (fail-closed), matching `ControlsCount`. + PlayerFilter::TrackedSetPossessor { + filter, + relation: _, + possession: _, + caused_by: _, + } => Axes { + event: false, + sibling: true, + projected: false, + } + .or(scan_target_filter( + filter, + FilterReadContext::LiveBoardCensus, + mode, + )), } } diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 5189826299..454dc6368e 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -1819,7 +1819,7 @@ fn fmt_quantity_ref(qty: &QuantityRef) -> String { } fn fmt_player_filter(pf: &PlayerFilter) -> String { - use crate::types::ability::{DamageKindFilter, PlayerRelation}; + use crate::types::ability::{DamageKindFilter, PlayerRelation, PossessionAxis}; match pf { PlayerFilter::Controller => "you", PlayerFilter::Opponent => "each opponent", @@ -1896,6 +1896,25 @@ fn fmt_player_filter(pf: &PlayerFilter) -> String { }; return format!("{who} whose {attr:?} {comparator:?} {value:?}"); } + // CR 608.2c + CR 109.4: "each [player class] who controlled/owned a + // [filter] this way" + PlayerFilter::TrackedSetPossessor { + relation, + possession, + filter, + .. + } => { + let who = match relation { + PlayerRelation::Controller => "you", + PlayerRelation::Opponent => "each opponent", + PlayerRelation::All => "each player", + }; + let verb = match possession { + PossessionAxis::Controller => "controlled", + PossessionAxis::Owner => "owned", + }; + return format!("{who} who {verb} a {filter:?} this way"); + } } .into() } @@ -7745,6 +7764,9 @@ fn player_filter_feature(scope: &PlayerFilter) -> (&'static str, FeatureSupport) PlayerFilter::ParentObjectTargetOwner => ("ParentObjectTargetOwner", Handled), PlayerFilter::ControlsCount { .. } => ("ControlsCount", Handled), PlayerFilter::PlayerAttribute { .. } => ("PlayerAttribute", Handled), + // CR 608.2c + CR 109.4: resolved by `quantity::possessed_tracked_set_member` + // via both `resolve_player_count` and `matches_player_scope`. + PlayerFilter::TrackedSetPossessor { .. } => ("TrackedSetPossessor", Handled), } } diff --git a/crates/engine/src/game/effects/deal_damage.rs b/crates/engine/src/game/effects/deal_damage.rs index fced3100bf..79a7a12c0c 100644 --- a/crates/engine/src/game/effects/deal_damage.rs +++ b/crates/engine/src/game/effects/deal_damage.rs @@ -1929,6 +1929,30 @@ fn collect_matching_players( ) .is_some_and(|lhs| comparator.evaluate(lhs, threshold)) } + // CR 608.2c + CR 608.2h + CR 109.4: candidate satisfies both + // `relation` and possession of a member of the most recent + // tracked object set. Delegates to the shared authority. + PlayerFilter::TrackedSetPossessor { + ref relation, + ref possession, + ref filter, + ref caused_by, + } => { + crate::game::players::matches_relation( + state, + p.id, + source_controller, + *relation, + ) && crate::game::quantity::possessed_tracked_set_member( + state, + p.id, + *possession, + filter, + *caused_by, + source_controller, + source_id, + ) + } } }) .map(|p| p.id) @@ -2172,6 +2196,30 @@ pub fn resolve_each_player( ) .is_some_and(|lhs| comparator.evaluate(lhs, threshold)) } + // CR 608.2c + CR 608.2h + CR 109.4: candidate satisfies both + // `relation` and possession of a member of the most recent + // tracked object set. Delegates to the shared authority. + PlayerFilter::TrackedSetPossessor { + relation, + possession, + filter, + caused_by, + } => { + crate::game::players::matches_relation( + state, + p.id, + ability.controller, + *relation, + ) && crate::game::quantity::possessed_tracked_set_member( + state, + p.id, + *possession, + filter, + *caused_by, + ability.controller, + ability.source_id, + ) + } } }) .map(|p| p.id) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 7e0278c4ad..cb56d0c19c 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -532,6 +532,28 @@ pub(crate) fn matches_player_scope( && candidate_player_scalar_with_state(state, p, controller, attr) .is_some_and(|lhs| comparator.evaluate(lhs, threshold)) } + // CR 608.2c + CR 608.2h + CR 109.4: "each player who + // controlled/owned a this way" — the candidate must + // satisfy both `relation` and possession of a member of the + // most recent tracked object set. Delegates to the single + // authority shared with `quantity::resolve_player_count`. + PlayerFilter::TrackedSetPossessor { + relation, + possession, + filter, + caused_by, + } => { + crate::game::players::matches_relation(state, p.id, controller, *relation) + && crate::game::quantity::possessed_tracked_set_member( + state, + p.id, + *possession, + filter, + *caused_by, + controller, + source_id, + ) + } } }) } @@ -4648,21 +4670,26 @@ fn effect_references_tracked_set(effect: &Effect) -> bool { fn quantity_expr_references_tracked_set(qty: &QuantityExpr) -> bool { match qty { QuantityExpr::Fixed { .. } => false, - QuantityExpr::Ref { qty } => { - matches!( - qty, - QuantityRef::TrackedSetSize - | QuantityRef::FilteredTrackedSetSize { .. } - | QuantityRef::TrackedSetAggregate { .. } - | QuantityRef::DistinctCardTypes { - source: CardTypeSetSource::TrackedSet { .. } - } - | QuantityRef::DistinctSubtypes { - source: CardTypeSetSource::TrackedSet { .. }, - .. - } - ) - } + QuantityExpr::Ref { qty } => match qty { + QuantityRef::TrackedSetSize + | QuantityRef::FilteredTrackedSetSize { .. } + | QuantityRef::TrackedSetAggregate { .. } + | QuantityRef::DistinctCardTypes { + source: CardTypeSetSource::TrackedSet { .. }, + } + | QuantityRef::DistinctSubtypes { + source: CardTypeSetSource::TrackedSet { .. }, + .. + } => true, + // CR 608.2c: a player-count whose filter is keyed on the chain's + // tracked object set is a CONSUMER of that set — the preceding + // producer must publish it, or the count resolves to 0. This is the + // Seasoned Pyromancer (#740) shape one layer up: the tracked-set + // reference is nested inside the PLAYER filter, not the quantity. + // Not every `PlayerCount` qualifies, so it must be asked per filter. + QuantityRef::PlayerCount { filter } => player_filter_references_tracked_set(filter), + _ => false, + }, QuantityExpr::Offset { inner, .. } | QuantityExpr::ClampMin { inner, .. } | QuantityExpr::Multiply { inner, .. } @@ -4679,6 +4706,57 @@ fn quantity_expr_references_tracked_set(qty: &QuantityExpr) -> bool { } } +/// CR 608.2c: Does this player filter read the chain's tracked object set? +/// +/// EXHAUSTIVE BY DESIGN — no `_` arm, and it must stay that way. Its sibling +/// predicates (`quantity_expr_references_tracked_set`, +/// `filter_references_tracked_set`) are `matches!`/wildcard allowlists that a +/// new variant joins silently and WRONGLY: a non-listed consumer compiles +/// clean, its producer never publishes, and the quantity resolves to 0 instead +/// of its real value. This one makes the compiler demand an answer. Grouped `|` +/// arms keep it readable; adding a variant to the `false` group is a decision, +/// not an accident. +fn player_filter_references_tracked_set(filter: &PlayerFilter) -> bool { + match filter { + // Reads `tracked_object_sets` + `tracked_set_member_causes`, which are + // published only when `next_sub_needs_tracked_set` reports a consumer. + PlayerFilter::TrackedSetPossessor { .. } => true, + // Reads `last_zone_changed_ids` — a DIFFERENT ledger, unconditionally + // recomputed after every effect and needing no publication gate. + PlayerFilter::ZoneChangedThisWay + // Reads the CR 701.x `player_actions_this_way` ledger. + | PlayerFilter::PerformedActionThisWay { .. } + // Plain relations, turn/combat ledgers, event-context anchors, vote + // ballots, linked-exile piles and per-candidate board/scalar + // comparisons — none consults `tracked_object_sets`. + | PlayerFilter::Controller + | PlayerFilter::Opponent + | PlayerFilter::DefendingPlayer + | PlayerFilter::OpponentLostLife + | PlayerFilter::OpponentGainedLife + | PlayerFilter::HasLostTheGame + | PlayerFilter::OpponentDealtDamage { .. } + | PlayerFilter::OpponentAttacked { .. } + | PlayerFilter::OpponentAttackingEnchantedPlayer + | PlayerFilter::All + | PlayerFilter::HighestSpeed + | PlayerFilter::OwnersOfCardsExiledBySource + | PlayerFilter::TriggeringPlayer + | PlayerFilter::OpponentOtherThanTriggering + | PlayerFilter::OpponentOfTriggeringPlayer + | PlayerFilter::OpponentOfTriggeringPlayerNotAttacked + | PlayerFilter::VotedFor { .. } + | PlayerFilter::ParentObjectTargetController + | PlayerFilter::ParentObjectTargetOwner + | PlayerFilter::ChosenPlayer { .. } + | PlayerFilter::ControlsCount { .. } + | PlayerFilter::PlayerAttribute { .. } => false, + // The negation wrapper inherits its inner filter's consumption: an + // "all except " scope still needs the set. + PlayerFilter::AllExcept { exclude } => player_filter_references_tracked_set(exclude), + } +} + fn filter_references_tracked_set(filter: &TargetFilter) -> bool { match filter { // CR 603.7: Both the bare tracked-set filter and its type-filtered @@ -11764,6 +11842,7 @@ fn scoped_player_matches_filter( | PlayerFilter::ChosenPlayer { .. } | PlayerFilter::ParentObjectTargetOwner | PlayerFilter::ControlsCount { .. } + | PlayerFilter::TrackedSetPossessor { .. } | PlayerFilter::PlayerAttribute { .. } => false, } } @@ -12882,6 +12961,65 @@ mod tests { ); } + /// CR 608.2c — issue #6943 (Faerie Slumber Party). The Seasoned Pyromancer + /// shape ONE LAYER UP: the tracked-set reference is nested inside the PLAYER + /// filter of a `PlayerCount`, not in the quantity itself. + /// + /// This is the cheapest layer at which the de-registration regression is + /// detectable, and its signature here is unique. `PlayerCount` is not + /// intrinsically a tracked-set consumer, so the enclosing predicate must ask + /// `player_filter_references_tracked_set` per filter. If it does not, the + /// producing `BounceAll` never publishes, the set selection returns `None`, + /// every player is rejected, and the count silently resolves to 0 — the card + /// creates ZERO tokens instead of six, with nothing failing to compile. + /// + /// Revert discriminator: dropping the `QuantityRef::PlayerCount` arm from + /// `quantity_expr_references_tracked_set` (i.e. restoring the `matches!` + /// allowlist) makes the first assertion fail. + #[test] + fn repeat_for_player_count_over_tracked_set_possessors_references_tracked_set() { + let mut ability = optional_gain_life(ObjectId(1), PlayerId(0), 1); + ability.optional = false; + assert!( + !ability_or_branch_references_tracked_set(&ability), + "baseline ability must not reference a tracked set" + ); + + // Faerie Slumber Party's repeat_for: "for each opponent who controlled a + // creature returned this way". + ability.repeat_for = Some(QuantityExpr::Ref { + qty: QuantityRef::PlayerCount { + filter: PlayerFilter::TrackedSetPossessor { + relation: crate::types::ability::PlayerRelation::Opponent, + possession: crate::types::ability::PossessionAxis::Controller, + filter: TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + ..Default::default() + }), + caused_by: None, + }, + }, + }); + assert!( + ability_or_branch_references_tracked_set(&ability), + "repeat_for: PlayerCount over TrackedSetPossessor is a tracked-set CONSUMER — \ + without this the producer never publishes and the count resolves to 0" + ); + + // Paired negative: the arm must be FILTER-discriminating, not a blanket + // `PlayerCount => true` that would make every existing player-count card + // force a spurious tracked-set publication. + ability.repeat_for = Some(QuantityExpr::Ref { + qty: QuantityRef::PlayerCount { + filter: PlayerFilter::Opponent, + }, + }); + assert!( + !ability_or_branch_references_tracked_set(&ability), + "a plain PlayerCount{{Opponent}} reads no tracked set and must NOT force publication" + ); + } + #[test] fn token_power_toughness_tracked_set_marks_ability_as_referencing_tracked_set() { let tracked_pt = PtValue::Quantity(QuantityExpr::Ref { diff --git a/crates/engine/src/game/effects/speed_effects.rs b/crates/engine/src/game/effects/speed_effects.rs index 53c3f45267..549f8f3a8a 100644 --- a/crates/engine/src/game/effects/speed_effects.rs +++ b/crates/engine/src/game/effects/speed_effects.rs @@ -337,6 +337,33 @@ pub(crate) fn players_for_filter( .map(|player| player.id) .collect() } + // CR 608.2c + CR 608.2h + CR 109.4: "each [player class] who + // controlled/owned a [filter] this way" — candidates satisfying both + // `relation` and possession of a member of the most recent tracked + // object set. Delegates to the shared possession authority. + PlayerFilter::TrackedSetPossessor { + relation, + possession, + filter, + caused_by, + } => state + .players + .iter() + .filter(|player| !player.is_eliminated) + .filter(|player| { + crate::game::players::matches_relation(state, player.id, controller, *relation) + && crate::game::quantity::possessed_tracked_set_member( + state, + player.id, + *possession, + filter, + *caused_by, + controller, + source_id, + ) + }) + .map(|player| player.id) + .collect(), } } diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 952299c3b2..a8f2e124c3 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3181,6 +3181,9 @@ fn player_filter_reads_life(pf: &PlayerFilter) -> bool { PlayerFilter::PlayerAttribute { attr, value, .. } => { quantity_ref_reads_life(attr) || quantity_expr_reads_life(value) } + // CR 608.2c + CR 109.4: the tracked-set possession predicate applies its + // object `filter` to each member; route it like `ControlsCount`. + PlayerFilter::TrackedSetPossessor { filter, .. } => target_filter_reads_life_total(filter), // Payload-free player sets — none read the life family. Enumerated // explicitly (no wildcard). PlayerFilter::Controller diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index d9ea4186dc..0c4ae28c6a 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -18,9 +18,9 @@ use crate::game::speed::effective_speed; use crate::types::ability::{ AggregateFunction, AttackScope, BasicLandType, CardTypeSetSource, CastManaObjectScope, CastManaSpentMetric, ContinuousModification, ControllerRef, CountScope, DamageChannel, - FilterProp, ObjectProperty, ObjectScope, PlayerFilter, PlayerScope, QuantityExpr, QuantityRef, - ResolvedAbility, RoundingMode, StaticCondition, SubtypeExclusion, TargetFilter, TargetRef, - TrackedAnaphorSource, TypeFilter, TypedFilter, ZoneRef, + FilterProp, ObjectProperty, ObjectScope, PlayerFilter, PlayerScope, PossessionAxis, + QuantityExpr, QuantityRef, ResolvedAbility, RoundingMode, StaticCondition, SubtypeExclusion, + TargetFilter, TargetRef, ThisWayCause, TrackedAnaphorSource, TypeFilter, TypedFilter, ZoneRef, }; use crate::types::card_type::CoreType; use crate::types::counter::{positive_counter_types, CounterType}; @@ -6422,6 +6422,95 @@ pub(crate) fn opponent_dealt_damage_matches( false } +/// CR 608.2c + CR 608.2h + CR 109.4: Did `player` possess a member of the most +/// recent tracked object set matching `filter` (and `caused_by`, when bound)? +/// +/// Single authority for `PlayerFilter::TrackedSetPossessor`, shared by the count +/// path (`resolve_player_count`) and the recipient path +/// (`effects::matches_player_scope`) — those two carry explicit "must stay in +/// sync" contracts, so the predicate is written once and delegated to twice. +/// +/// Set selection, the cause gate, and the live-vs-LKI filter branch mirror +/// `QuantityRef::FilteredTrackedSetSize` exactly; only the final possession gate +/// is new. `.any()` gives distinct-player semantics for free — a player who +/// possessed three members is still one player. +pub(crate) fn possessed_tracked_set_member( + state: &GameState, + player: PlayerId, + possession: PossessionAxis, + filter: &TargetFilter, + caused_by: Option, + controller: PlayerId, + source_id: ObjectId, +) -> bool { + let Some((set_id, ids)) = state.tracked_object_sets.iter().max_by_key(|(id, _)| id.0) else { + return false; + }; + let filter_ctx = FilterContext::from_source_with_controller(source_id, controller); + ids.iter().any(|&oid| { + // CR 608.2c + CR 614.6: an action-bound population ("a creature + // sacrificed this way") admits only members whose recorded producer + // ACTION equals the bound cause — keyed on the action, not the final + // zone, because CR 614.6 lets a replacement redirect the member + // elsewhere (a sacrifice sent to exile is still `Sacrificed`). + // `None` accepts every member. + let cause_ok = match caused_by { + None => true, + Some(cause) => state + .tracked_set_member_causes + .get(set_id) + .and_then(|causes| causes.get(&oid)) + .is_some_and(|member_cause| *member_cause == cause), + }; + if !cause_ok { + return false; + } + // CR 608.2h: a member that has left the battlefield is filtered on its + // last known information. + let matches_filter = if state.battlefield.contains(&oid) { + matches_target_filter(state, oid, filter, &filter_ctx) + } else { + state.lki_cache.get(&oid).map_or_else( + || matches_target_filter(state, oid, filter, &filter_ctx), + |lki| { + crate::game::filter::matches_target_filter_on_lki_snapshot( + state, + oid, + lki, + filter, + &filter_ctx, + ) + }, + ) + }; + if !matches_filter { + return false; + } + let holder = match possession { + PossessionAxis::Controller => { + if state.battlefield.contains(&oid) { + // CR 109.4: an object ON the battlefield HAS a controller, + // so read it live. Its `lki_cache` entry, if any, is a stale + // snapshot from an EARLIER battlefield exit and must not win. + state.objects.get(&oid).map(|o| o.controller) + } else { + // CR 109.4 + CR 608.2h: off the battlefield it has NO + // controller, so last known information is the only answer. + // Deliberately NO owner fallback: crediting the owner is + // precisely the wrong answer for a stolen creature, and a + // silent one. A member that never was on the battlefield or + // in exile has no LKI and matches nobody — correct, since + // "who controlled it" is unanswerable under CR 109.4. + state.lki_cache.get(&oid).map(|lki| lki.controller) + } + } + // CR 108.3: owner is stable across zone changes. + PossessionAxis::Owner => state.objects.get(&oid).map(|o| o.owner), + }; + holder == Some(player) + }) +} + /// Count players matching a PlayerFilter relative to the controller. pub(crate) fn resolve_player_count( state: &GameState, @@ -6666,6 +6755,30 @@ pub(crate) fn resolve_player_count( ) .is_some_and(|lhs| comparator.evaluate(lhs, threshold)) } + // CR 608.2c + CR 608.2h + CR 109.4: "for each opponent + // who controlled a creature returned this way" — count + // candidates satisfying both the `relation` predicate + // and possession of a tracked-set member. Delegates to + // the single authority shared with + // `effects::matches_player_scope`. + PlayerFilter::TrackedSetPossessor { + relation, + possession, + filter, + caused_by, + } => { + crate::game::players::matches_relation( + state, p.id, controller, *relation, + ) && possessed_tracked_set_member( + state, + p.id, + *possession, + filter, + *caused_by, + controller, + source_id, + ) + } } }) .count(), @@ -16296,6 +16409,170 @@ mod tests { ); } + /// H3 (issue #6943) — MULTI-AUTHORITY. CR 109.4: for a member that has left + /// the battlefield, the `Controller` axis reads the AT-EXIT CONTROLLER from + /// last known information (CR 608.2h), never the owner. + /// + /// The fixture is a stolen creature: owned by P0 (the caster), controlled by + /// P1 (an opponent) when it was bounced. "Each opponent who controlled a + /// creature returned this way" must count P1 — 1 player. + /// + /// This genuinely discriminates: an owner-keyed implementation (the mistake + /// `PlayerFilter::ZoneChangedThisWay` makes, which reads `obj.owner`) credits + /// P0, who is not an opponent, and the count is 0. The two readings differ, + /// so the fixture cannot pass under both. + #[test] + fn tracked_set_possessor_controller_axis_reads_lki_controller_not_owner() { + let mut state = GameState::new_two_player(42); + // Owned by P0, but P1 controlled it when it left the battlefield. + let stolen = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Stolen Creature".into(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&stolen).unwrap(); + obj.card_types.core_types = vec![CoreType::Creature]; + obj.controller = PlayerId(1); + } + let lki = state.objects[&stolen].snapshot_public_characteristics(); + assert_eq!(lki.owner, PlayerId(0), "fixture: owner is the caster"); + assert_eq!( + lki.controller, + PlayerId(1), + "fixture: controller is the opponent — the two authorities MUST differ, \ + or this fixture cannot discriminate" + ); + // Bounce it: off the battlefield, so CR 109.4 leaves no live controller. + state.lki_cache.insert(stolen, lki); + state.battlefield.retain(|id| *id != stolen); + state.objects.get_mut(&stolen).unwrap().zone = Zone::Hand; + + let set_id = TrackedSetId(state.next_tracked_set_id); + state.next_tracked_set_id += 1; + state.tracked_object_sets.insert(set_id, vec![stolen]); + state.chain_tracked_set_id = Some(set_id); + + let creature = TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)); + assert!( + possessed_tracked_set_member( + &state, + PlayerId(1), + PossessionAxis::Controller, + &creature, + None, + PlayerId(0), + ObjectId(999), + ), + "P1 CONTROLLED the returned creature — the Controller axis must credit them" + ); + assert!( + !possessed_tracked_set_member( + &state, + PlayerId(0), + PossessionAxis::Controller, + &creature, + None, + PlayerId(0), + ObjectId(999), + ), + "P0 merely OWNED it — an owner-keyed implementation would wrongly credit them" + ); + // H7: the same fixture read on the Owner axis flips the answer, proving + // `possession` is a live parameter rather than a dead one. CR 108.3. + assert!( + possessed_tracked_set_member( + &state, + PlayerId(0), + PossessionAxis::Owner, + &creature, + None, + PlayerId(0), + ObjectId(999), + ), + "the Owner axis must credit the owner (Kefka, Dancing Mad's reading)" + ); + assert!( + !possessed_tracked_set_member( + &state, + PlayerId(1), + PossessionAxis::Owner, + &creature, + None, + PlayerId(0), + ObjectId(999), + ), + "the Owner axis must NOT credit the controller" + ); + } + + /// H8 (issue #6943) — LIVE vs LKI ordering. CR 109.4: an object that IS on + /// the battlefield HAS a controller, so it is read LIVE. Its `lki_cache` + /// entry may be a stale snapshot from an EARLIER battlefield exit (the + /// Sudden Salvation shape: permanents that died this turn and were returned) + /// and must not win. + /// + /// Revert discriminator: an LKI-first implementation reads P1 and both + /// assertions flip. + #[test] + fn tracked_set_possessor_prefers_live_controller_for_on_battlefield_member() { + let mut state = GameState::new_two_player(42); + let member = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Returned Permanent".into(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&member).unwrap(); + obj.card_types.core_types = vec![CoreType::Creature]; + obj.controller = PlayerId(0); + } + // A STALE snapshot from an earlier battlefield exit naming a different + // controller. It must be ignored while the object is on the battlefield. + let mut stale_lki = state.objects[&member].snapshot_public_characteristics(); + stale_lki.controller = PlayerId(1); + state.lki_cache.insert(member, stale_lki); + assert!( + state.battlefield.contains(&member), + "fixture: the member must be ON the battlefield for this branch" + ); + + let set_id = TrackedSetId(state.next_tracked_set_id); + state.next_tracked_set_id += 1; + state.tracked_object_sets.insert(set_id, vec![member]); + state.chain_tracked_set_id = Some(set_id); + + let creature = TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)); + assert!( + possessed_tracked_set_member( + &state, + PlayerId(0), + PossessionAxis::Controller, + &creature, + None, + PlayerId(0), + ObjectId(999), + ), + "the LIVE controller must win for an on-battlefield member" + ); + assert!( + !possessed_tracked_set_member( + &state, + PlayerId(1), + PossessionAxis::Controller, + &creature, + None, + PlayerId(0), + ObjectId(999), + ), + "the STALE LKI controller must not win for an on-battlefield member" + ); + } + /// Occult Epiphany #3307: "Draw X, then discard X. Create a 1/1 Spirit for /// each card type among cards discarded this way." Draw and Discard MERGE /// into one chain tracked set; the token count must be DISTINCT CARD TYPES diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 2a9293c2db..e5a2a52461 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -9863,6 +9863,10 @@ fn evaluate_trigger_condition_with_source( // CR 402.1 / 119.1 / 122.1f / 404.1: a player-scalar population // predicate is likewise set-valued — no "whose turn" semantic. | PlayerFilter::PlayerAttribute { .. } + // CR 608.2c: a tracked-set possession predicate is set-valued (every + // player who possessed a member) — no single-player "whose turn" + // semantic. Fail-closed alongside the other set-valued variants. + | PlayerFilter::TrackedSetPossessor { .. } // CR 506.2 + CR 508.6: a count-only attacked-opponents predicate is // set-valued — no single-player "whose turn" semantic. Fail-closed. | PlayerFilter::OpponentOfTriggeringPlayerNotAttacked diff --git a/crates/engine/src/parser/oracle_quantity.rs b/crates/engine/src/parser/oracle_quantity.rs index 956ee48e57..1b2b2a1482 100644 --- a/crates/engine/src/parser/oracle_quantity.rs +++ b/crates/engine/src/parser/oracle_quantity.rs @@ -20,7 +20,7 @@ use std::str::FromStr; use crate::parser::oracle_nom::error::{OracleError, OracleResult}; use nom::branch::alt; use nom::bytes::complete::{tag, take_till1, take_until}; -use nom::combinator::{all_consuming, eof, map, opt, peek, value}; +use nom::combinator::{all_consuming, eof, map, map_opt, opt, peek, rest, value}; use nom::multi::separated_list1; use nom::sequence::{pair, preceded, terminated}; use nom::Parser; @@ -41,8 +41,9 @@ use crate::parser::oracle_util::merge_or_filters; use crate::types::ability::{ AggregateFunction, AttackScope, AttackSubject, Comparator, ControllerRef, CountScope, DamageChannel, DamageKindFilter, DevotionColors, FilterProp, ObjectProperty, ObjectScope, - PlayerFilter, PlayerRelation, PlayerScope, QuantityExpr, QuantityRef, RoundingMode, - TargetFilter, ThisWayCause, TrackedAnaphorSource, TypeFilter, TypedFilter, ZoneRef, + PlayerFilter, PlayerRelation, PlayerScope, PossessionAxis, QuantityExpr, QuantityRef, + RoundingMode, TargetFilter, ThisWayCause, TrackedAnaphorSource, TypeFilter, TypedFilter, + ZoneRef, }; use crate::types::counter::CounterType; use crate::types::events::PlayerActionKind; @@ -2686,6 +2687,89 @@ fn parse_investigated_arm(input: &str) -> nom::IResult<&str, PlayerActionKind, O .parse(input) } +/// Normalize the two existing " this way" tails to a +/// common `(filter, cause)` pair. This CALLS them; it does not restate either +/// verb table, so both keep their single authority over their own verbs. +/// +/// - `parse_filtered_landing_zone_this_way` — the returned / put-into-a-graveyard +/// table. Its only return shape is `FilteredTrackedSetSize { filter, +/// caused_by: None }`; the `if let` asserts that structurally rather than +/// assuming it. `caused_by` stays `None` because the cause is derived from the +/// producing effect's DESTINATION (`this_way_cause_for_effect` maps +/// `Battlefield ⇒ Returned`, `Hand ⇒ Bounced`) while the parser sees only the +/// English verb "returned", which both destinations print. +/// - `parse_destroyed_or_sacrificed_this_way_filter` — the destroyed / +/// sacrificed / milled / discarded / exiled table, which is +/// action-discriminated and so carries a real cause. +/// +/// A `(None, cause)` result is REJECTED rather than mapped to a permissive +/// filter: that delegate returns it for two different reasons — the type phrase +/// was trivial, OR it did not fully consume. Mapping either to "no restriction" +/// would conflate "unrestricted" with "not understood" and silently mis-filter. +/// Rejecting lets the clause fall through to the existing `TrackedSetSize` +/// fallback, i.e. exactly today's behaviour, keeping the gap visible. +fn parse_this_way_filter_and_cause(lower: &str) -> Option<(TargetFilter, Option)> { + if let Some(QuantityRef::FilteredTrackedSetSize { filter, caused_by }) = + parse_filtered_landing_zone_this_way(lower) + { + return Some((*filter, caused_by)); + } + match parse_destroyed_or_sacrificed_this_way_filter(lower)? { + (Some(filter), cause) => Some((filter, Some(cause))), + (None, _) => None, + } +} + +/// CR 608.2c + CR 608.2h + CR 109.4: "[population] who controlled|owned +/// [article] this way" → a player count over the possessors of +/// the preceding effect's tracked object set (Faerie Slumber Party: "for each +/// opponent who controlled a creature returned this way"). +/// +/// Distinct from `parse_action_this_way`, its sibling one function up: that one +/// recognizes what a player DID (a CR 701.x keyword action, keyed on the +/// `player_actions_this_way` ledger); this one recognizes what a player +/// POSSESSED (a CR 108.3/109.4 relation, keyed on the published tracked set). +/// +/// Nested by prefix — population → `"who "` → possession verb → article — and +/// the OBJECT TAIL is delegated whole to the two existing this-way filter +/// helpers, so no verb table is restated here. Every axis is exactly one +/// `alt()`; there is no permutation enumeration. +fn parse_tracked_set_possessor_this_way( + input: &str, +) -> OracleResult< + '_, + ( + PlayerRelation, + PossessionAxis, + TargetFilter, + Option, + ), +> { + let (input, relation) = parse_player_population(input)?; + let (input, _) = tag("who ").parse(input)?; + // CR 108.3 / CR 109.4: one `alt()` on the possession axis. Both tenses are + // the same relation ("controls" on Sudden Salvation, "owns" on Kefka); they + // cost one `tag` each and are not reachable from this dispatch site today + // because neither card carries the "this way" anaphor — noted, not hidden. + let (input, possession) = alt(( + value( + PossessionAxis::Controller, + alt((tag("controlled "), tag("controls "))), + ), + value(PossessionAxis::Owner, alt((tag("owned "), tag("owns ")))), + )) + .parse(input)?; + // Leading article, stripped with a local `alt()` of `tag()`s exactly as + // `parse_searched_arm` does. ("one of those " is the demonstrative-anaphor + // form Guff/Sudden Salvation use.) + let (input, _) = opt(alt((tag("a "), tag("an "), tag("one of those ")))).parse(input)?; + // The tail — including its own " this way" terminator — is consumed whole by + // the delegated verb tables. + let (input, (filter, caused_by)) = + map_opt(rest, parse_this_way_filter_and_cause).parse(input)?; + Ok((input, (relation, possession, filter, caused_by))) +} + /// "opponent who does" / "players who do" → accepted the optional offer. fn parse_optional_offer_accepted_clause( input: &str, @@ -3064,10 +3148,12 @@ fn parse_for_each_clause_with_they_controller( // Tempting Offer cycle's bonus-tutor-per-accepting-opponent step and // Wernog's bonus-investigate-per-investigating-opponent step. A single // verb-dispatched combinator handles every (population × verb tense × - // article) permutation, returning a player-count quantity rather than - // the object-count `TrackedSetSize` fallback below. Must be tried before - // that fallback because every "[population] who … this way" clause does - // contain "this way". + // article) permutation OF A KEYWORD-ACTION VERB, returning a player-count + // quantity rather than the object-count `TrackedSetSize` fallback below. + // Possession verbs ("controlled"/"owned") are a different relation and + // are handled by the sibling arm immediately below, not here. Both must + // be tried before that fallback because every "[population] who … this + // way" clause does contain "this way". if let Ok((rest, (relation, action))) = parse_action_this_way(lower.as_str()) { if rest.is_empty() { return Some(QuantityRef::PlayerCount { @@ -3075,6 +3161,26 @@ fn parse_for_each_clause_with_they_controller( }); } } + // CR 608.2c + CR 608.2h + CR 109.4: "[population] who controlled a + // this way" counts PLAYERS who possessed a member of the + // preceding effect's tracked set — a different axis from the + // object-count `TrackedSetSize` fallback below, which would count the + // returned creatures themselves (Faerie Slumber Party #6943). Placed + // AFTER `parse_action_this_way` so the Tempting Offer cycle still + // matches its action arm first, and BEFORE the fallback because every + // such clause contains "this way". + if let Ok(("", (relation, possession, filter, caused_by))) = + parse_tracked_set_possessor_this_way(lower.as_str()) + { + return Some(QuantityRef::PlayerCount { + filter: PlayerFilter::TrackedSetPossessor { + relation, + possession, + filter, + caused_by, + }, + }); + } // CR 608.2c + CR 122.1: "[counter-type] counter[s] removed this way" — the // numeric amount of counters removed by the preceding `Effect::RemoveCounter` // in the sub-ability chain. The parent-effect-aware scan in @@ -7409,6 +7515,76 @@ mod tests { } } + /// T2 (issue #6943) — the AST pin for Faerie Slumber Party's second + /// sentence: "For each opponent who controlled a creature returned this + /// way, you create two … tokens." + /// + /// CR 608.2c + CR 109.4: this counts PLAYERS on the possession axis. Before + /// the fix the clause fell through to the bare `TrackedSetSize` fallback and + /// counted the returned CREATURES instead — a silent wrong-answer misparse + /// (9 creatures × 2 = 18 tokens instead of 3 opponents × 2 = 6). + /// + /// This is the paired POSITIVE that stops the anti-swallow negatives below + /// from being vacuously satisfied by a fallback or an `Unimplemented` + /// early-return: it proves the new arm actually fires on the real clause. + #[test] + fn for_each_opponent_who_controlled_a_creature_returned_this_way_counts_players() { + let qty = parse_for_each_clause("opponent who controlled a creature returned this way") + .expect("must parse"); + assert_eq!( + qty, + QuantityRef::PlayerCount { + filter: PlayerFilter::TrackedSetPossessor { + relation: PlayerRelation::Opponent, + possession: PossessionAxis::Controller, + filter: TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + ..Default::default() + }), + // Derived from the producing effect's DESTINATION, which the + // parser cannot see — both "returned to hand" and "returned + // to the battlefield" print the same verb. `None` accepts + // every member and is strictly more robust than pinning a + // cause the parser would have to guess. + caused_by: None, + }, + }, + "the possession-axis clause must count players, not the returned objects" + ); + } + + /// T3 (issue #6943) anti-swallow — Paradoxical Outcome: "Draw a card for + /// each card returned to your hand this way." Its clause has no + /// "[population] who" prefix, so the new possession arm must not touch it; + /// it stays the bare object-count `TrackedSetSize`. + /// + /// The realistic failure mode this guards is a SCANNING (non-anchored) + /// implementation. The shipped one is anchored at `parse_player_population` + /// → `tag("who ")`, so it structurally cannot reach this clause. + #[test] + fn paradoxical_outcome_card_returned_to_your_hand_this_way_stays_tracked_set_size() { + let qty = parse_for_each_clause("card returned to your hand this way").expect("must parse"); + assert_eq!( + qty, + QuantityRef::TrackedSetSize, + "a bare object-count 'returned this way' clause must be unaffected" + ); + } + + /// T4 (issue #6943) anti-swallow — Revival Experiment: "You lose 3 life for + /// each card returned this way." Same guard as T3 on the shortest form of + /// the "returned" verb, where a scanning implementation would be most + /// likely to over-match. + #[test] + fn revival_experiment_card_returned_this_way_stays_tracked_set_size() { + let qty = parse_for_each_clause("card returned this way").expect("must parse"); + assert_eq!( + qty, + QuantityRef::TrackedSetSize, + "a bare 'card returned this way' clause must be unaffected" + ); + } + /// CR 608.2c + CR 701.20b: "nonland card revealed this way" (Selvala, /// Explorer Returned parley) must emit `FilteredTrackedSetSize` with a /// nonland filter and no producer-action binding. diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 4fa9aa4b43..4ed11bb88e 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -6748,6 +6748,26 @@ pub enum PlayerRelation { All, } +/// CR 108.3 + CR 109.4: Which possession relation binds a player to an object. +/// +/// A parameter, not a variant pair. The codebase already proliferates this axis +/// as siblings (`PlayerFilter::ParentObjectTargetController` / +/// `ParentObjectTargetOwner`, whose own doc calls it "completing the +/// owner/controller pair"), and within one tracked-set snapshot the two +/// readings differ only in which recorded field answers the same question. +/// Both values have a shipped card: Faerie Slumber Party (`Controller`), +/// Kefka, Dancing Mad (`Owner`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PossessionAxis { + /// CR 109.4 + CR 608.2h: controller. Only objects on the stack or the + /// battlefield have a controller, so a member that has left the + /// battlefield is read from last known information. + Controller, + /// CR 108.3: owner — the player who started the game with the card in + /// their deck. Stable across zone changes. + Owner, +} + /// CR 506.2 + CR 508.1b: Whose attacks the "opponents attacked" player set is /// measured over. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -7015,6 +7035,49 @@ pub enum PlayerFilter { /// player facing the choice is the owner of the targeted permanent named in /// the prior clause, not the ability controller. ParentObjectTargetOwner, + /// CR 608.2c + CR 608.2h + CR 109.4 + CR 102.2: Each player matching + /// `relation` who possessed — per `possession` — at least one member of the + /// most recent tracked object set matching `filter`, restricted to members + /// whose recorded producer action equals `caused_by` when it is `Some`. + /// + /// Powers "for each opponent who controlled a creature returned this way" + /// (Faerie Slumber Party): the *set* of returned creatures is only the + /// membership test; the count is over the DISTINCT PLAYERS who possessed a + /// member. Counting the objects instead (bare `QuantityRef::TrackedSetSize`) + /// is a different quantity on a different axis. + /// + /// CR 109.4: a member no longer on the battlefield has NO controller, so + /// `Controller` falls back to `lki_cache[id].controller` (CR 608.2h last + /// known information) for exactly those members — and reads LIVE state for + /// members still on the battlefield, whose LKI entry may be a stale + /// snapshot from an earlier battlefield exit. `Owner` reads + /// `objects[id].owner` (CR 108.3, stable across zone changes). + /// + /// Reads the PUBLISHED tracked set (`tracked_object_sets`), which the + /// producing effect fills only when `next_sub_needs_tracked_set` is true. + /// `player_filter_references_tracked_set` (`game/effects/mod.rs`) MUST + /// report this variant as a consumer or the producer never publishes and + /// the count silently resolves to 0. + /// + /// USED IN THE COUNT POSITION ONLY (`repeat_for` / `QuantityRef::PlayerCount`). + /// Before using it as an `ability.player_scope`, see issue #6957: + /// `is_player_scope_local_continuation` ends on + /// `matches!(scope, PlayerFilter::All)`, so a scope-position variant it does + /// not list silently returns `false` and its continuation is detached as an + /// unscoped tail — the same silent-default mechanism as the publication + /// allowlist above, in a different predicate. + /// + /// Deliberately distinct from `ZoneChangedThisWay` (the unconditional, + /// unfiltered `last_zone_changed_ids` ledger) and `PerformedActionThisWay` + /// (the CR 701.x player-action ledger). The three share an English suffix, + /// not a ledger. + TrackedSetPossessor { + relation: PlayerRelation, + possession: PossessionAxis, + filter: TargetFilter, + #[serde(default, skip_serializing_if = "Option::is_none")] + caused_by: Option, + }, } /// An expression that produces an integer for quantity comparisons. diff --git a/crates/engine/tests/integration/issue_6943_faerie_slumber_party.rs b/crates/engine/tests/integration/issue_6943_faerie_slumber_party.rs new file mode 100644 index 0000000000..13989c807d --- /dev/null +++ b/crates/engine/tests/integration/issue_6943_faerie_slumber_party.rs @@ -0,0 +1,230 @@ +//! Runtime pipeline regression — Faerie Slumber Party (issue #6943). +//! +//! Oracle: "Return all creatures to their owners' hands. For each opponent who +//! controlled a creature returned this way, you create two 1/1 blue Faerie +//! creature tokens with flying and "This token can block only creatures with +//! flying."" +//! +//! Two independent quantities live in the second sentence: the SET of creatures +//! returned, and the COUNT OF DISTINCT OPPONENTS who controlled at least one +//! member of it. The token count is driven by the second; the first is only the +//! membership test. The parser collapsed the clause onto the bare object-count +//! `QuantityRef::TrackedSetSize` fallback, so the card created two tokens per +//! RETURNED CREATURE (including the caster's own) instead of two per OPPONENT. +//! +//! CR 608.2c (this-way back-reference) + CR 608.2h (last known information) + +//! CR 109.4 (only objects on the battlefield have a controller). +//! +//! ## Reading the numbers — 0 is NOT a neutral result here +//! +//! Three outcomes are distinguishable and every assertion below names all of +//! them, because two DIFFERENT defects both produce a plausible-looking count: +//! +//! - **18** — the reported bug: object-count `TrackedSetSize` (9 creatures × 2). +//! - **0** — the tracked set was never PUBLISHED. `PlayerFilter::TrackedSetPossessor` +//! is a consumer of `tracked_object_sets`, which the producing `BounceAll` +//! fills only when `next_sub_needs_tracked_set` reports a consumer. That +//! predicate bottoms out in an allowlist that a new variant joins silently and +//! wrongly; if the variant is missing from it, set selection returns `None`, +//! every player is rejected, and the count is 0 — with nothing failing to +//! compile. `effects::tests::repeat_for_player_count_over_tracked_set_possessors_ +//! references_tracked_set` is the dedicated guard for that seam. +//! - **6** — correct: 3 opponents × 2. +//! +//! A test that merely asserted "not 18" could be satisfied by the 0 regression. +//! Every zero-expecting fixture below is therefore TWO-POINT: the same board is +//! re-run with one opponent creature added and must yield a NON-zero count, so 0 +//! is always contrastive and can never be reached by a dead producer. + +use engine::game::scenario::GameScenario; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; + +const P0: PlayerId = PlayerId(0); +const P1: PlayerId = PlayerId(1); +const P2: PlayerId = PlayerId(2); +const P3: PlayerId = PlayerId(3); + +/// Verbatim Oracle text. A paraphrase can take a different parser branch and go +/// green while the real card stays broken, so this is copied byte-for-byte from +/// `data/card-data.json`. +const FAERIE_SLUMBER_PARTY: &str = "Return all creatures to their owners' hands. \ +For each opponent who controlled a creature returned this way, you create two 1/1 blue \ +Faerie creature tokens with flying and \"This token can block only creatures with flying.\""; + +/// Build a 4-player board with `creatures[i]` vanilla creatures under player i, +/// cast Faerie Slumber Party from P0, resolve it, and return +/// `(faerie_tokens_created, creatures_still_on_battlefield)`. +fn run_slumber_party(creatures: [usize; 4]) -> (usize, usize) { + let mut scenario = GameScenario::new_n_player(4, 42); + scenario.at_phase(Phase::PreCombatMain); + + for (idx, count) in creatures.iter().enumerate() { + let player = PlayerId(idx as u8); + for _ in 0..*count { + scenario.add_vanilla(player, 2, 2); + } + } + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Faerie Slumber Party", false, FAERIE_SLUMBER_PARTY) + .id(); + + let mut runner = scenario.build(); + // Fund {4}{U}{U}. + for _ in 0..6 { + let unit = ManaUnit::new(ManaType::Blue, ObjectId(0), false, vec![]); + runner.state_mut().players[0].mana_pool.add(unit); + } + + runner.cast(spell).resolve(); + + let tokens = runner + .state() + .battlefield + .iter() + .filter(|&&id| { + runner + .state() + .objects + .get(&id) + .is_some_and(|o| o.is_token && o.name == "Faerie") + }) + .count(); + let creatures_left = runner + .state() + .battlefield + .iter() + .filter(|&&id| { + runner.state().objects.get(&id).is_some_and(|o| { + !o.is_token + && o.card_types + .core_types + .contains(&engine::types::card_type::CoreType::Creature) + }) + }) + .count(); + (tokens, creatures_left) +} + +/// T1 — the reported scenario. P0 controls 3 creatures, P1 one, P2 two, P3 +/// three (9 total). All three opponents controlled a returned creature, so the +/// count is 3 opponents × 2 tokens = 6. +/// +/// REVERT DISCRIMINATOR: this is the assertion that flips. Reverting the parser +/// arm restores `repeat_for: Ref(TrackedSetSize)` and yields 18; dropping the +/// `PlayerCount` arm from `quantity_expr_references_tracked_set` yields 0. +#[test] +fn faerie_slumber_party_creates_two_tokens_per_opponent_not_per_creature() { + let (tokens, creatures_left) = run_slumber_party([3, 1, 2, 3]); + + assert_eq!( + tokens, 6, + "expected 6 Faerie tokens (3 opponents × 2). \ + 18 ⇒ object-count TrackedSetSize regression (9 returned creatures × 2); \ + 0 ⇒ the tracked set was never published (consumer-allowlist de-registration); \ + 6 ⇒ correct" + ); + // The bounce must actually have happened, so a 0 result can be attributed to + // the player count rather than to a producer that silently did nothing. + assert_eq!( + creatures_left, 0, + "all 9 creatures must have been returned to their owners' hands — \ + the fix must not pass by breaking the bounce" + ); +} + +/// H1 — TWO-POINT. The `relation: Opponent` gate: creatures the CASTER +/// controlled must not be counted. +/// +/// (a) P0 controls 3 creatures and no opponent controls any → 0 tokens. Before +/// the fix this counted P0's own 3 creatures and produced 6. +/// (b) The same board plus ONE P1 creature → 2 tokens. This is the paired +/// non-zero reading that makes (a)'s 0 contrastive rather than absolute. +#[test] +fn faerie_slumber_party_ignores_creatures_the_caster_controlled() { + let (tokens_none, _) = run_slumber_party([3, 0, 0, 0]); + assert_eq!( + tokens_none, 0, + "no OPPONENT controlled a returned creature ⇒ 0 tokens. \ + 6 ⇒ the caster's own 3 creatures were counted (missing Opponent gate)" + ); + + let (tokens_one, _) = run_slumber_party([3, 1, 0, 0]); + assert_eq!( + tokens_one, 2, + "exactly one opponent controlled a returned creature ⇒ 2 tokens. \ + 0 here would mean the tracked set is never published, which would also \ + explain the 0 above — this pairing is what tells the two apart" + ); +} + +/// H2 — distinct-player semantics. ONE opponent controlling FOUR creatures is +/// still ONE opponent: 2 tokens, not 8. Guards the `.any()` over members +/// (rather than a per-member tally). +#[test] +fn faerie_slumber_party_counts_each_opponent_once_regardless_of_creature_count() { + let (tokens, _) = run_slumber_party([0, 4, 0, 0]); + assert_eq!( + tokens, 2, + "one opponent with four returned creatures is ONE player ⇒ 2 tokens. \ + 8 ⇒ counted per creature instead of per distinct player" + ); +} + +/// H5 — TWO-POINT empty-path guard. +/// +/// (a) An empty battlefield must yield 0 tokens and must not panic (the set +/// selection returns `None`). +/// (b) The same board plus one opponent creature must yield 2. +/// +/// ⚠️ Per the matrix's own rule, part (a) CARRIES NO EVIDENCE ABOUT THE COUNT on +/// its own: 0 is exactly the observable the consumer-allowlist de-registration +/// produces. It may be read only alongside part (b) and +/// `faerie_slumber_party_creates_two_tokens_per_opponent_not_per_creature` being +/// green at 6. A future reader must not treat a passing (a) as coverage of the +/// count. +#[test] +fn faerie_slumber_party_empty_battlefield_is_zero_without_panicking() { + let (tokens_empty, _) = run_slumber_party([0, 0, 0, 0]); + assert_eq!( + tokens_empty, 0, + "nothing was returned ⇒ 0 tokens, and no panic on the empty tracked set" + ); + + // NOTE the caster creature: with a board of exactly ONE opponent creature + // and nothing else, the pre-fix object count (1 × 2) and the correct player + // count (1 × 2) COINCIDE at 2, so such a fixture passes at base and proves + // nothing. Adding two caster creatures separates them — base yields 6. + let (tokens_one, _) = run_slumber_party([2, 0, 1, 0]); + assert_eq!( + tokens_one, 2, + "the SAME code path with one opponent creature must produce 2 — \ + this is what distinguishes the legitimate 0 above from a dead producer. \ + 6 ⇒ the pre-fix object count" + ); +} + +/// Every opponent seat must be reachable and counted, so the count tracks +/// distinct opponents rather than a single hard-coded seat. +/// +/// Each fixture deliberately also gives the CASTER two creatures. Without them +/// the board would hold exactly one creature, and the pre-fix object count +/// (1 × 2) would coincide with the correct player count (1 × 2) at 2 — the test +/// would pass at base and discriminate nothing. With them, base yields 6. +#[test] +fn faerie_slumber_party_counts_each_distinct_opponent_seat() { + for (idx, seat) in [P1, P2, P3].iter().enumerate() { + let mut creatures = [0usize; 4]; + creatures[0] = 2; + creatures[idx + 1] = 1; + let (tokens, _) = run_slumber_party(creatures); + assert_eq!( + tokens, 2, + "opponent seat {seat:?} must be counted like any other, and the \ + caster's own two creatures must not be counted. 6 ⇒ pre-fix object count" + ); + } +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 4924af90ef..34a971b839 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -653,6 +653,7 @@ mod issue_6858_draw_that_many_discard; mod issue_688_mind_into_matter; mod issue_689_resonating_lute_hand_size; mod issue_691_sheoldred_saga_lore; +mod issue_6943_faerie_slumber_party; mod issue_709_regression; mod issue_718_dina_sacrifice_draw; mod issue_735_amalia_power_threshold;