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
98 changes: 87 additions & 11 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,14 @@ pub(crate) fn matches_player_scope(
PlayerFilter::AllExcept { exclude } => {
!matches_player_scope(state, p.id, exclude, controller, source_id)
}
PlayerFilter::Opponent => p.id != controller,
// CR 102.2 / CR 102.3: "opponent" is a game-topology
// relation, not raw player-ID inequality. In team
// multiplayer a teammate is neither the controller nor an
// opponent, so effect-recipient enumeration must route
// through the canonical `players::is_opponent` authority.
PlayerFilter::Opponent => {
crate::game::players::is_opponent(state, controller, p.id)
}
PlayerFilter::DefendingPlayer => {
crate::game::targeting::resolve_event_context_target_for_event_or_state(
state,
Expand All @@ -351,10 +358,12 @@ pub(crate) fn matches_player_scope(
)
}
PlayerFilter::OpponentLostLife => {
p.id != controller && p.life_lost_this_turn > 0
crate::game::players::is_opponent(state, controller, p.id)
&& p.life_lost_this_turn > 0
}
PlayerFilter::OpponentGainedLife => {
p.id != controller && p.life_gained_this_turn > 0
crate::game::players::is_opponent(state, controller, p.id)
&& p.life_gained_this_turn > 0
}
// CR 104.5 / CR 800.4: Players who lost have left the game;
// this filter is quantity-only and has no live effect recipient.
Expand Down Expand Up @@ -382,7 +391,7 @@ pub(crate) fn matches_player_scope(
),
// CR 508.6: opponent the subject attacked within scope.
PlayerFilter::OpponentAttacked { subject, scope } => {
p.id != controller
crate::game::players::is_opponent(state, controller, p.id)
&& state
.opponent_attacked(*subject, *scope, controller, source_id, p.id)
}
Expand All @@ -393,7 +402,7 @@ pub(crate) fn matches_player_scope(
// never appear in `combat.attackers` for `DefendingPlayer`),
// resolved via the shared event-context target resolver.
PlayerFilter::OpponentAttackingEnchantedPlayer => {
p.id != controller
crate::game::players::is_opponent(state, controller, p.id)
&& enchanted_player_anchor(state, source_id).is_some_and(|enchanted| {
state.player_attacked_player_this_combat(p.id, enchanted)
})
Expand Down Expand Up @@ -10780,9 +10789,9 @@ pub(crate) fn evaluate_condition(
/// `quantity::resolve_player_count`, but applied to one already-bound iteration
/// player rather than a fold over all players. Set-valued / event-context
/// variants that have no single-player semantic outside an iteration loop fail
/// closed`ScopedPlayerMatches` is only emitted by the parser for `All` /
/// `Opponent` / `Controller` today (the canonical decline-tail scopes), so the
/// fall-through is defensive rather than load-bearing.
/// closed. `ScopedPlayerMatches` is emitted for decline tails and for
/// relationship-qualified scoped phase-player conditions; both surfaces use
/// the same printed-controller-relative player relation semantics.
fn scoped_player_matches_filter(
state: &GameState,
ability: &ResolvedAbility,
Expand All @@ -10802,7 +10811,10 @@ fn scoped_player_matches_filter(
let controller = ability.original_controller.unwrap_or(ability.controller);
match filter {
PlayerFilter::Controller => candidate == controller,
PlayerFilter::Opponent => candidate != controller,
// CR 102.2 / CR 102.3: "opponent" is a game-topology relation, not raw
// player-ID inequality. In team multiplayer, a teammate is neither the
// controller nor an opponent.
PlayerFilter::Opponent => crate::game::players::is_opponent(state, controller, candidate),
PlayerFilter::All => true,
// CR 608.2c + CR 109.4: candidate matches unless it matches the anchor.
// Recursive against the same printed-controller-relative predicate;
Expand All @@ -10811,15 +10823,15 @@ fn scoped_player_matches_filter(
!scoped_player_matches_filter(state, ability, candidate, exclude)
}
PlayerFilter::OpponentLostLife => {
candidate != controller
crate::game::players::is_opponent(state, controller, candidate)
&& state
.players
.iter()
.find(|p| p.id == candidate)
.is_some_and(|p| p.life_lost_this_turn > 0)
}
PlayerFilter::OpponentGainedLife => {
candidate != controller
crate::game::players::is_opponent(state, controller, candidate)
&& state
.players
.iter()
Expand Down Expand Up @@ -25700,6 +25712,70 @@ mod tests {
}
}

/// CR 102.2 / CR 102.3 + CR 109.5: the production
/// `ScopedPlayerMatches` evaluator uses canonical team topology and the
/// printed controller preserved in `original_controller`. This test covers
/// only the relation predicate; it does not claim full Fevered Visions
/// support under CR 805.4d's multi-trigger fanout.
#[test]
fn scoped_player_matches_filter_uses_team_opponents_and_original_controller() {
let mut state = GameState::new(FormatConfig::two_headed_giant(), 4, 42);
let definition = AbilityDefinition::new(
AbilityKind::Spell,
Effect::Draw {
count: QuantityExpr::Fixed { value: 1 },
target: TargetFilter::Controller,
},
);
let mut ability = build_resolved_from_def(&definition, ObjectId(9000), PlayerId(0));

// Model the live player-scope iteration: the driver has rebound the
// acting controller to opponent P2, while the printed controller P0 is
// retained as the relationship authority.
ability.controller = PlayerId(2);
ability.original_controller = Some(PlayerId(0));

for (candidate, expected) in [
(PlayerId(0), false),
(PlayerId(1), false),
(PlayerId(2), true),
(PlayerId(3), true),
] {
assert_eq!(
scoped_player_matches_filter(&state, &ability, candidate, &PlayerFilter::Opponent),
expected,
"2HG opponent relation mismatch for {candidate:?}"
);
}

// Both the teammate and one opponent have matching history. The
// teammate must still fail the relation leg; the other opponent lacks
// history and must fail the history leg.
for player in state.players.iter_mut() {
if matches!(player.id, PlayerId(1) | PlayerId(2)) {
player.life_lost_this_turn = 1;
player.life_gained_this_turn = 1;
}
}
for filter in [
PlayerFilter::OpponentLostLife,
PlayerFilter::OpponentGainedLife,
] {
assert!(
!scoped_player_matches_filter(&state, &ability, PlayerId(1), &filter),
"teammate history must not satisfy an opponent-history filter"
);
assert!(
scoped_player_matches_filter(&state, &ability, PlayerId(2), &filter),
"opponent with matching history must satisfy {filter:?}"
);
assert!(
!scoped_player_matches_filter(&state, &ability, PlayerId(3), &filter),
"opponent without matching history must fail {filter:?}"
);
}
}

/// CR 102.3 (issue #4361): in Two-Headed Giant, the caster's TEAMMATE is not
/// an opponent, so `PlayerFilter::OpponentOfTriggeringPlayer` must exclude
/// them. With a 2HG topology (seats 0,1 = team 0; seats 2,3 = team 1) and a
Expand Down
109 changes: 108 additions & 1 deletion crates/engine/src/parser/oracle_effect/conditions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ use nom::Parser;

use super::super::oracle_nom::bridge::{nom_on_lower, nom_parse_lower};
use super::super::oracle_nom::condition::{
inject_controller_you, parse_cast_using_teamwork_phrase, parse_spell_target_superlative_suffix,
inject_controller_you, parse_cast_using_teamwork_phrase,
parse_scoped_player_opponent_and_has_condition, parse_spell_target_superlative_suffix,
parse_you_put_onto_battlefield_this_way_clause, parse_zone_changed_this_way_clause,
};
use super::super::oracle_nom::primitives as nom_primitives;
Expand Down Expand Up @@ -5793,6 +5794,34 @@ pub(super) fn try_nom_condition_as_ability_condition(
return Some(maybe_negate(cond, negated));
}

// CR 102.2 / CR 102.3 + CR 603.2b + CR 608.2c: A phase trigger over
// "each player's" step binds `ScopedPlayer`; a later leading condition can
// constrain that SAME player by both relationship and scalar state:
// "the player is your opponent and has <hand/life predicate>".
//
// The grammar returns both independent legs. Compose them here from
// existing conditions: `ScopedPlayerMatches(Opponent)` plus the bridged
// `HandSize/LifeTotal { ScopedPlayer }` quantity check. Full consumption
// and the exact context equality are both required. Other relative-player
// contexts (triggering/target/defending/chosen/etc.) deliberately fall
// through unchanged rather than reinterpreting their referent as a
// per-player phase iteration.
if matches!(
ctx.relative_player_scope.as_ref(),
Some(ControllerRef::ScopedPlayer)
) {
if let Ok((_, (filter, scalar))) =
all_consuming(parse_scoped_player_opponent_and_has_condition).parse(lower.as_str())
{
return Some(AbilityCondition::And {
conditions: vec![
AbilityCondition::ScopedPlayerMatches { filter },
static_condition_to_ability_condition(&scalar, ctx)?,
],
});
}
}

let (rest, condition) = parse_inner_condition(&lower).ok()?;
if !rest.trim().is_empty() {
return None;
Expand Down Expand Up @@ -6947,6 +6976,7 @@ mod tests {
use super::*;
use crate::parser::oracle_nom::condition::parse_inner_condition;
use crate::parser::parse_oracle_text;
use crate::types::ability::PlayerFilter;
use crate::types::counter::{CounterMatch, CounterType};

/// CR 400.7 + CR 608.2c: S07 Batch 1 — the leading-"if" active-voice
Expand Down Expand Up @@ -7108,6 +7138,83 @@ mod tests {
);
}

/// CR 102.2 / CR 102.3 + CR 603.2b + CR 608.2c: a leading
/// relationship-qualified phase-player condition is preserved with its
/// body and lowers to both existing condition legs. This exercises the
/// production leading-condition splitter, not just the leaf bridge.
#[test]
fn scoped_phase_player_opponent_and_hand_gate_preserves_leading_body() {
let mut ctx = ParseContext {
relative_player_scope: Some(ControllerRef::ScopedPlayer),
..Default::default()
};
let (condition, body) = strip_leading_general_conditional(
"If the player is your opponent and has four or more cards in hand, this enchantment deals 2 damage to that player",
&mut ctx,
);

assert_eq!(
body, "this enchantment deals 2 damage to that player",
"the leading gate must be peeled without changing the body"
);
assert_eq!(
condition,
Some(AbilityCondition::And {
conditions: vec![
AbilityCondition::ScopedPlayerMatches {
filter: PlayerFilter::Opponent,
},
AbilityCondition::QuantityCheck {
lhs: QuantityExpr::Ref {
qty: QuantityRef::HandSize {
player: PlayerScope::ScopedPlayer,
},
},
comparator: Comparator::GE,
rhs: QuantityExpr::Fixed { value: 4 },
},
],
})
);
}

/// The relationship grammar itself succeeds for every row (the reach
/// guard), but the effect bridge must decline unless the surrounding
/// context is exactly `ScopedPlayer`. This prevents an early grammar
/// failure from making any negative assertion vacuous.
#[test]
fn scoped_phase_player_opponent_bridge_declines_other_relative_scopes() {
let input = "the player is your opponent and has four or more cards in hand";
let assert_declines = |scope: Option<ControllerRef>| {
assert!(
all_consuming(parse_scoped_player_opponent_and_has_condition)
.parse(input)
.is_ok(),
"reach guard: the direct relationship grammar must succeed"
);
let mut ctx = ParseContext {
relative_player_scope: scope.clone(),
..Default::default()
};
assert_eq!(
try_nom_condition_as_ability_condition(input, &mut ctx),
None,
"non-scoped relative player must be declined: {scope:?}"
);
};

for scope in [
Some(ControllerRef::TriggeringPlayer),
Some(ControllerRef::TargetPlayer),
Some(ControllerRef::ParentTargetController),
Some(ControllerRef::DefendingPlayer),
Some(ControllerRef::SourceChosenPlayer),
None,
] {
assert_declines(scope);
}
}

#[test]
fn strip_target_keyword_instead_parses_toxic_as_typed_keyword() {
// "if that creature has toxic, ..." must lower to a real Toxic keyword,
Expand Down
12 changes: 12 additions & 0 deletions crates/engine/src/parser/oracle_ir/snapshot_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,18 @@ fn dark_confidant() {
insta::assert_json_snapshot!("dark_confidant_lowered", &lowered);
}

#[test]
fn fevered_visions() {
let (ir, lowered) = parse_two_layer(
"At the beginning of each player's end step, that player draws a card. If the player is your opponent and has four or more cards in hand, this enchantment deals 2 damage to that player.",
"Fevered Visions",
&["Enchantment"],
&[],
);
insta::assert_json_snapshot!("fevered_visions_ir", &ir);
insta::assert_json_snapshot!("fevered_visions_lowered", &lowered);
}

#[test]
fn eidolon_of_the_great_revel() {
let (ir, lowered) = parse_two_layer(
Expand Down
Loading
Loading