Skip to content
Open
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
19 changes: 19 additions & 0 deletions crates/engine/src/database/synthesis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15521,6 +15521,25 @@ mod dethrone_tests {
trigger.condition.is_some(),
"dethrone trigger must have an intervening-if condition"
);
// TRIPWIRE (CR 702.105a + CR 603.2 / CR 603.4). Dethrone reads "Whenever
// this creature attacks the player with the most life or tied for most
// life" — there is no "if", so by CR 603.4's own parenthetical the
// most-life clause is part of the TRIGGER EVENT (CR 603.2) and must be
// checked once, at declaration. Modelling it as a `TriggerCondition` is
// over-strict: the ability is wrongly removed from the stack if life
// totals change in response.
//
// That defect is KNOWN, DEFERRED, and deliberately NOT changed here; the
// destination is `TargetFilter::PlayerMatching` on `valid_target`, the
// same channel Namor, Atlantean King and Owlbear Cub now use. This
// assertion pins the current shape so the migration must be a deliberate
// edit rather than an accidental drift.
assert_eq!(
trigger.valid_target, None,
"TRIPWIRE: Dethrone still routes its most-life clause through \
`condition`, not the CR 603.2 `valid_target` channel. Migrating it \
to `PlayerMatching` must update this assertion on purpose."
);
}

#[test]
Expand Down
11 changes: 11 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1358,6 +1358,7 @@ fn scope_of(target: &TargetFilter, chain_root: Option<WriteScope>) -> WriteScope
| TargetFilter::SpecificObject { .. }
| TargetFilter::SpecificPlayer { .. }
| TargetFilter::PlayerWhoChoseLabel { .. }
| TargetFilter::PlayerMatching { .. }
| TargetFilter::Neighbor { .. }
| TargetFilter::ScopedPlayer
| TargetFilter::AttachedTo
Expand Down Expand Up @@ -2249,6 +2250,11 @@ fn legacy_controller_ref(x: &ControllerRef) -> bool {
/// serde oracle's whole-value walk). `ParentTargetSlot` is deliberately excluded.
fn legacy_target_filter(f: &TargetFilter) -> bool {
match f {
// CR 102.1: the player-axis crossing. `legacy_player_filter` is the
// authority for whether a player predicate carries a legacy-12 tag
// (`PlayerAttribute`'s quantity payloads can), so delegate rather than
// flattening this to `false`.
TargetFilter::PlayerMatching { player } => legacy_player_filter(player),
TargetFilter::TriggeringSpellController
| TargetFilter::TriggeringSpellOwner
| TargetFilter::TriggeringPlayer
Expand Down Expand Up @@ -2560,6 +2566,7 @@ fn member_bound_target_filter(f: &TargetFilter) -> bool {
| TargetFilter::SpecificObject { .. }
| TargetFilter::SpecificPlayer { .. }
| TargetFilter::PlayerWhoChoseLabel { .. }
| TargetFilter::PlayerMatching { .. }
| TargetFilter::DefendingPlayer
| TargetFilter::Named { .. }
| TargetFilter::Owner
Expand Down Expand Up @@ -6670,6 +6677,10 @@ fn rw_target_filter(x: &TargetFilter) -> RwProfile {
}
// CR 607.2d / CR 607.2m (by analogy): durable per-player anchor-label reads.
TargetFilter::PlayerWhoChoseLabel { label: _ } => reads_player_of(StateKind::Other),
// CR 102.1: an arbitrary player predicate reads whatever its payload
// reads (life totals, controlled-permanent counts, attack history), so
// delegate to the player-axis profiler instead of flattening it here.
TargetFilter::PlayerMatching { player } => rw_player_filter(player),
// CR 608.2h + CR 113.7a: source-controller resolution follows the
// source's exact live-or-LKI incarnation.
TargetFilter::SourceController => reads_src_of(StateKind::Other),
Expand Down
51 changes: 51 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3112,6 +3112,11 @@ fn scan_target_filter(x: &TargetFilter, ctx: FilterReadContext, mode: ScanMode)
},
TargetFilter::SourceChosenPlayer => Axes::NONE,
TargetFilter::PlayerWhoChoseLabel { label: _ } => Axes::NONE,
// CR 102.1: the nested player predicate can itself read projected state
// (`ControlsCount` over a whole `TargetFilter`, `PlayerAttribute` over a
// `QuantityExpr`), so RECURSE rather than reporting `Axes::NONE` —
// mirroring the object-axis `FilterProp::ControllerMatches` arm.
TargetFilter::PlayerMatching { player } => scan_player_filter(player, mode),
TargetFilter::OriginalController => Axes::NONE,
TargetFilter::PostReplacementSourceController => Axes {
event: true,
Expand Down Expand Up @@ -8039,4 +8044,50 @@ mod tests {
assert!(!axes.sibling);
assert!(!axes.projected);
}

/// V13 — `TargetFilter::PlayerMatching` recursion is CLASSIFIED, not
/// blind-defaulted to `Axes::NONE`.
///
/// CR 102.1: the nested `PlayerFilter` can read projected per-player state
/// (`PlayerAttribute` over a life total) and can box a whole `TargetFilter`
/// (`ControlsCount`). Reporting `Axes::NONE` for it would let trigger
/// ordering auto-resolve a group whose members really do read
/// order-relevant state.
///
/// Revert-failing: replace the recursive arm with `Axes::NONE` and the
/// `projected` assertion below flips.
#[test]
fn player_matching_scan_recurses_into_the_nested_player_filter() {
let payload = PlayerFilter::PlayerAttribute {
relation: crate::types::ability::PlayerRelation::All,
attr: Box::new(QuantityRef::LifeTotal {
player: PlayerScope::ScopedPlayer,
}),
comparator: Comparator::GT,
value: Box::new(QuantityExpr::Ref {
qty: QuantityRef::LifeTotal {
player: PlayerScope::Controller,
},
}),
};
let scanned = scan_target_filter(
&TargetFilter::PlayerMatching {
player: Box::new(payload.clone()),
},
FilterReadContext::SnapshotOrEvent,
ScanMode::Conservative,
);
let direct = scan_player_filter(&payload, ScanMode::Conservative);

// The carrier must report exactly what its payload reports, on every axis.
assert_eq!(scanned.event, direct.event);
assert_eq!(scanned.sibling, direct.sibling);
assert_eq!(scanned.projected, direct.projected);
// …and that report must be non-empty: a life-total predicate reads
// projected per-player state, so `Axes::NONE` would be a blind default.
assert!(
scanned.event || scanned.sibling || scanned.projected,
"PlayerMatching over a life-total predicate must not scan as NONE"
);
}
}
Loading
Loading