From 1844c91ba51f91b2b1c83968eb28c2e0c2338c47 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Mon, 3 Aug 2026 05:24:19 -0700 Subject: [PATCH] fix(parser): count opponents, not returned creatures, for Faerie Slumber Party (#6943) Faerie Slumber Party created 18 tokens where 6 were correct. "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..." -- the token count is driven by the number of DISTINCT OPPONENTS who controlled at least one returned creature, and the returned set is only the membership test. The parser dropped the "opponent who controlled" wrapper entirely. The emitted AST carried `repeat_for: Ref(TrackedSetSize)` -- the size of the returned set -- with no player-count node anywhere on the card. The resolver was correct; it did exactly what the AST said. 9 creatures returned x 2 tokens = 18. Add a `PlayerFilter::TrackedSetPossessor` shape so the count resolves over players who possess a member of the tracked set, and parse the "for each who ... this way" grammar with nom combinators composed from the existing oracle_nom helpers. The dangerous half of this change is the registration, not the parse. `quantity_expr_references_tracked_set` gates whether the PRODUCER publishes the tracked set at all, and it was a `matches!` allowlist -- a hand-maintained registry the compiler cannot census. Retargeting `repeat_for` to a shape absent from that allowlist would have de-registered the card as a consumer, so `BounceAll` would never publish, every membership test would fail, and the result would be 0 tokens rather than 6 -- with no compile error. That is the shape of the Seasoned Pyromancer bug (#740). So rather than adding one arm to the allowlist, extract `player_filter_references_tracked_set` as an exhaustive, wildcard-free match. A new `PlayerFilter` variant now cannot compile until it declares whether it reads the gated ledger. Adding a variant to the `false` group becomes a decision rather than an accident. 0 tokens is the de-registration signature and collides with the legitimate empty-board result, so the tests are built to distinguish them: T1 was observed RED at exactly 18 at base -- proving the tracked set genuinely publishes there and that 18 and 0 are separable outcomes -- then GREEN at 6. Removing only the allowlist entry reproduces the predicted 0, and a unit-layer test catches that directly. Verified end to end rather than at AST level: the card-data pipeline was rerun and the regenerated data carries the new shape. Parser-side, so it stays inert in the app until card data is regenerated and redeployed. Deferred and filed: a `PlayerFilter` in SCOPE position is still classified by a `matches!` predicate that silently detaches scoped continuations (#6957). This variant lands in the COUNT position, so that site is benign here. --- crates/engine/src/analysis/resource.rs | 10 +- crates/engine/src/game/ability_rw.rs | 18 ++ crates/engine/src/game/ability_scan.rs | 20 ++ crates/engine/src/game/coverage.rs | 24 +- crates/engine/src/game/effects/deal_damage.rs | 48 +++ crates/engine/src/game/effects/mod.rs | 168 ++++++++++- .../engine/src/game/effects/speed_effects.rs | 27 ++ crates/engine/src/game/layers.rs | 3 + crates/engine/src/game/quantity.rs | 283 +++++++++++++++++- crates/engine/src/game/triggers.rs | 4 + crates/engine/src/parser/oracle_quantity.rs | 190 +++++++++++- crates/engine/src/types/ability.rs | 63 ++++ .../issue_6943_faerie_slumber_party.rs | 230 ++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 14 files changed, 1059 insertions(+), 30 deletions(-) create mode 100644 crates/engine/tests/integration/issue_6943_faerie_slumber_party.rs 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;