Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions crates/engine/src/analysis/resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
18 changes: 18 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)),
}
}

Expand Down
24 changes: 23 additions & 1 deletion crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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),
}
}

Expand Down
48 changes: 48 additions & 0 deletions crates/engine/src/game/effects/deal_damage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
168 changes: 153 additions & 15 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <filter> 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,
)
}
}
})
}
Expand Down Expand Up @@ -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, .. }
Expand All @@ -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 <tracked-set possessor>" 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
Expand Down Expand Up @@ -11764,6 +11842,7 @@ fn scoped_player_matches_filter(
| PlayerFilter::ChosenPlayer { .. }
| PlayerFilter::ParentObjectTargetOwner
| PlayerFilter::ControlsCount { .. }
| PlayerFilter::TrackedSetPossessor { .. }
| PlayerFilter::PlayerAttribute { .. } => false,
}
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading