diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 414d1df19a..eb4b73b537 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -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, @@ -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. @@ -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) } @@ -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) }) @@ -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, @@ -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; @@ -10811,7 +10823,7 @@ 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() @@ -10819,7 +10831,7 @@ fn scoped_player_matches_filter( .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() @@ -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 diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index 09f8b931b3..2b1abef02f 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -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; @@ -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 ". + // + // 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; @@ -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 @@ -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| { + 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, diff --git a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs index 7a78ce4c8c..29305bfe48 100644 --- a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs @@ -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( diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_ir.snap new file mode 100644 index 0000000000..c6b757313b --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_ir.snap @@ -0,0 +1,120 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 169, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "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, ~ deals 2 damage to that player." + }, + "node": { + "PreLoweredTrigger": { + "mode": "Phase", + "execute": { + "kind": "Spell", + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "ScopedPlayer" + } + }, + "cost": null, + "sub_ability": { + "kind": "Spell", + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "ScopedPlayer" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": { + "type": "And", + "conditions": [ + { + "type": "ScopedPlayerMatches", + "filter": { + "type": "Opponent" + } + }, + { + "type": "QuantityCheck", + "lhs": { + "type": "Ref", + "qty": { + "type": "HandSize", + "player": { + "type": "ScopedPlayer" + } + } + }, + "comparator": "GE", + "rhs": { + "type": "Fixed", + "value": 4 + } + } + ] + }, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "sub_link": "SequentialSibling" + }, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "End", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": "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, ~ deals 2 damage to that player.", + "constraint": null, + "condition": null, + "batched": false + } + } + } + ], + "source_text": "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.", + "card_name": "Fevered Visions" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_lowered.snap new file mode 100644 index 0000000000..524c403251 --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__fevered_visions_lowered.snap @@ -0,0 +1,102 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [], + "triggers": [ + { + "mode": "Phase", + "execute": { + "kind": "Spell", + "effect": { + "type": "Draw", + "count": { + "type": "Fixed", + "value": 1 + }, + "target": { + "type": "ScopedPlayer" + } + }, + "cost": null, + "sub_ability": { + "kind": "Spell", + "effect": { + "type": "DealDamage", + "amount": { + "type": "Fixed", + "value": 2 + }, + "target": { + "type": "ScopedPlayer" + } + }, + "cost": null, + "sub_ability": null, + "duration": null, + "description": null, + "target_prompt": null, + "condition": { + "type": "And", + "conditions": [ + { + "type": "ScopedPlayerMatches", + "filter": { + "type": "Opponent" + } + }, + { + "type": "QuantityCheck", + "lhs": { + "type": "Ref", + "qty": { + "type": "HandSize", + "player": { + "type": "ScopedPlayer" + } + } + }, + "comparator": "GE", + "rhs": { + "type": "Fixed", + "value": 4 + } + } + ] + }, + "optional_targeting": false, + "optional": false, + "forward_result": false, + "sub_link": "SequentialSibling" + }, + "duration": null, + "description": null, + "target_prompt": null, + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + }, + "valid_card": null, + "origin": null, + "destination": null, + "trigger_zones": [ + "Battlefield" + ], + "phase": "End", + "optional": false, + "damage_kind": "Any", + "secondary": false, + "valid_target": null, + "valid_source": null, + "description": "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, ~ deals 2 damage to that player.", + "constraint": null, + "condition": null, + "batched": false + } + ], + "statics": [], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index 0b961a5957..8493598444 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -3600,6 +3600,58 @@ fn parse_life_predicate(rest: &str, player: PlayerScope) -> Option<(&str, Static None } +/// CR 102.2 / CR 102.3 + CR 608.2c: Parse a relationship-qualified predicate +/// on the current scoped player: +/// +/// `the player|that player` + `is your opponent` + `and has` + +/// a hand-size or life-total predicate. +/// +/// Three axes compose independently — subject, relation, and scalar predicate. +/// The relation is a real `alt()` axis (`parse_scoped_player_relation`) yielding +/// a `PlayerFilter`, and the scalar predicate reuses the existing +/// `PlayerScope::ScopedPlayer` hand/life productions. The effect-condition +/// bridge decides whether its parse context actually has a scoped player to +/// bind; this leaf deliberately does not infer one. +/// +/// The `" and has "` connector is deliberately fused into this production +/// rather than delegated to `parse_condition_connective` (the single authority +/// documented at the top of this module): that combinator is typed +/// `OracleResult<'_, StaticCondition>`, and `StaticCondition` has no +/// player-relationship variant — the relation exists only as +/// `AbilityCondition::ScopedPlayerMatches`. The generic connective therefore +/// structurally cannot join a relation leg to a scalar leg without a new +/// `StaticCondition` variant, which is an `add-engine-variant`-gated proposal. +pub(crate) fn parse_scoped_player_opponent_and_has_condition( + input: &str, +) -> OracleResult<'_, (PlayerFilter, StaticCondition)> { + let (rest, _) = alt(( + tag::<_, _, OracleError<'_>>("the player "), + tag("that player "), + )) + .parse(input)?; + let (rest, filter) = parse_scoped_player_relation(rest)?; + let (rest, _) = tag(" and has ").parse(rest)?; + let Some((rest, predicate)) = parse_hand_size_predicate(rest, PlayerScope::ScopedPlayer) + .or_else(|| parse_life_predicate(rest, PlayerScope::ScopedPlayer)) + else { + return Err(oracle_err(input)); + }; + Ok((rest, (filter, predicate))) +} + +/// CR 102.2 / CR 102.3: the relation axis of a scoped-player predicate — +/// which topology relation to the printed controller the scoped player must +/// hold. Mirrors the `parse_condition_connector` shape (`value()` arms inside +/// an `alt()`), so a further relation is a one-line arm rather than a +/// restructure. +fn parse_scoped_player_relation(input: &str) -> OracleResult<'_, PlayerFilter> { + alt((value( + PlayerFilter::Opponent, + tag::<_, _, OracleError<'_>>("is your opponent"), + ),)) + .parse(input) +} + /// Build a QuantityComparison: qty [comparator] n. fn make_quantity_comparison(qty: QuantityRef, comparator: Comparator, n: u32) -> StaticCondition { StaticCondition::QuantityComparison { @@ -9938,6 +9990,88 @@ mod tests { } } + /// CR 102.2 / CR 102.3 + CR 119 + CR 402.1 + CR 608.2c: the scoped-player + /// relationship grammar factors subject, opponent relation, connector, and + /// scalar tail. Both demonstrative subjects and every existing hand/life + /// comparator family produce an exact typed pair with no remainder. + #[test] + fn scoped_player_opponent_and_has_composes_subject_relation_and_scalar_axes() { + let hand = |comparator, value| StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::HandSize { + player: PlayerScope::ScopedPlayer, + }, + }, + comparator, + rhs: QuantityExpr::Fixed { value }, + }; + let life = |comparator, value| StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::LifeTotal { + player: PlayerScope::ScopedPlayer, + }, + }, + comparator, + rhs: QuantityExpr::Fixed { value }, + }; + + for (input, expected) in [ + ( + "the player is your opponent and has four or more cards in hand", + hand(Comparator::GE, 4), + ), + ( + "that player is your opponent and has one or fewer cards in hand", + hand(Comparator::LE, 1), + ), + ( + "the player is your opponent and has fewer than seven cards in hand", + hand(Comparator::LT, 7), + ), + ( + "that player is your opponent and has exactly three cards in hand", + hand(Comparator::EQ, 3), + ), + ( + "the player is your opponent and has 10 or less life", + life(Comparator::LE, 10), + ), + ( + "that player is your opponent and has 20 or more life", + life(Comparator::GE, 20), + ), + ] { + let (rest, parsed) = + parse_scoped_player_opponent_and_has_condition(input).expect("grammar must parse"); + assert_eq!(rest, "", "{input:?} left remainder {rest:?}"); + assert_eq!(parsed, (PlayerFilter::Opponent, expected), "{input:?}"); + } + } + + /// Adjacent phrases must not be accepted as the relationship-qualified + /// production. The all-consuming wrapper is load-bearing: a valid prefix + /// followed by semantic text is a rejection, not a partial success. + #[test] + fn scoped_player_opponent_and_has_rejects_hostile_adjacent_phrases() { + for input in [ + "a player is your opponent and has four or more cards in hand", + "the opponent is your opponent and has four or more cards in hand", + "the player isn't your opponent and has four or more cards in hand", + "the player is an opponent and has four or more cards in hand", + "the player is your opponent or has four or more cards in hand", + "the player is your opponent and had four or more cards in hand", + "the player is your opponent and has four or more cards in handbag", + "the player is your opponent and has four or more cards in hand and draws", + ] { + assert!( + nom::combinator::all_consuming(parse_scoped_player_opponent_and_has_condition) + .parse(input) + .is_err(), + "hostile adjacent phrase must fail closed: {input:?}" + ); + } + } + /// CR 402: "fewer than N cards in hand" — strict less-than hand-size gate. /// Used by Kozilek, the Great Distortion ("fewer than seven") and Iymrith, /// Desert Doom ("fewer than three") as the draw-difference condition. diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index c67a8a5547..03427b1429 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -14636,6 +14636,101 @@ fn phase_trigger_each_players_upkeep_no_constraint() { assert_eq!(def.constraint, None); } +/// CR 102.2 / CR 102.3 + CR 402.1 + CR 603.2b + CR 608.2c: Fevered +/// Visions keeps the phase trigger unconditional, draws for the scoped +/// phase-player first, then gates the damage instruction on BOTH that player's +/// opponent relation and post-draw hand size. +#[test] +fn fevered_visions_scoped_player_damage_gate_is_fully_typed() { + const ORACLE: &str = "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."; + + fn count_unimplemented(ability: &AbilityDefinition) -> usize { + usize::from(matches!( + ability.effect.as_ref(), + Effect::Unimplemented { .. } + )) + ability + .sub_ability + .as_deref() + .map(count_unimplemented) + .unwrap_or(0) + + ability + .else_ability + .as_deref() + .map(count_unimplemented) + .unwrap_or(0) + } + + let parsed = parse_oracle_text( + ORACLE, + "Fevered Visions", + &[], + &["Enchantment".to_string()], + &[], + ); + assert_eq!(parsed.triggers.len(), 1, "{:?}", parsed.triggers); + let trigger = &parsed.triggers[0]; + assert_eq!(trigger.mode, TriggerMode::Phase); + assert_eq!(trigger.phase, Some(Phase::End)); + assert_eq!( + trigger.condition, None, + "the damage rider is a resolution-time instruction condition, not an intervening-if" + ); + + let draw = trigger.execute.as_deref().expect("trigger execute"); + assert_eq!( + draw.effect.as_ref(), + &Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::ScopedPlayer, + } + ); + let damage = draw + .sub_ability + .as_deref() + .expect("conditional damage child"); + assert_eq!( + damage.effect.as_ref(), + &Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 2 }, + target: TargetFilter::ScopedPlayer, + damage_source: None, + excess: None, + } + ); + assert_eq!( + damage.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 }, + }, + ], + }) + ); + assert_eq!( + count_unimplemented(draw), + 0, + "Fevered Visions must contain no Unimplemented effects: {draw:?}" + ); + assert!( + parsed.parse_warnings.iter().all(|warning| !matches!( + warning, + OracleDiagnostic::SwallowedClause { detector, .. } if detector == "Condition_If" + )), + "Fevered Visions must not emit Condition_If: {:?}", + parsed.parse_warnings + ); +} + /// CR 603.2b + CR 608.2c: Roiling Vortex — "At the beginning of each player's /// upkeep, this enchantment deals 1 damage to them." The bare player anaphor /// "them" is the player whose upkeep it is — the same referent "that player" diff --git a/crates/engine/tests/integration/fevered_visions.rs b/crates/engine/tests/integration/fevered_visions.rs new file mode 100644 index 0000000000..e95db6c97d --- /dev/null +++ b/crates/engine/tests/integration/fevered_visions.rs @@ -0,0 +1,122 @@ +//! Fevered Visions resolves its printed end-step instructions in order: +//! the active player draws, then the post-draw opponent/hand-size gate decides +//! whether that player takes 2 damage. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; + +const FEVERED_VISIONS_ORACLE: &str = "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."; + +fn hand_count(runner: &engine::game::scenario::GameRunner, player: PlayerId) -> usize { + runner + .state() + .players + .iter() + .find(|candidate| candidate.id == player) + .expect("scenario player") + .hand + .len() +} + +/// Drive the production turn transition (postcombat main -> end step), trigger +/// placement, priority passes, and stack resolution. Returns `(draw_delta, +/// life_delta)` for the active player. +fn resolve_fevered_end_step(active_player: PlayerId, pre_draw_hand: usize) -> (isize, i32) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PostCombatMain); + for index in 0..pre_draw_hand { + scenario.add_card_to_hand(active_player, &format!("Hand Card {index}")); + } + scenario.with_library_top(active_player, &["Fevered Draw"]); + scenario + .add_creature(P0, "Fevered Visions", 0, 0) + .as_enchantment() + .from_oracle_text(FEVERED_VISIONS_ORACLE); + + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + state.active_player = active_player; + state.priority_player = active_player; + state.waiting_for = WaitingFor::Priority { + player: active_player, + }; + } + let hand_before = hand_count(&runner, active_player); + let life_before = runner.life(active_player); + + // CR 513.1 + CR 603.2b: pass real priority from postcombat main into the + // end step, where the beginning-of-step trigger is created. + for _ in 0..8 { + if runner.state().phase == Phase::End { + break; + } + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "unexpected prompt before end step: {:?}", + runner.state().waiting_for + ); + runner + .act(GameAction::PassPriority) + .expect("priority pass toward end step"); + } + assert_eq!( + runner.state().phase, + Phase::End, + "real turn machinery must enter the end step" + ); + assert!( + !runner.state().stack.is_empty() + || matches!(runner.state().waiting_for, WaitingFor::OrderTriggers { .. }), + "Fevered Visions trigger must be pending at the end-step boundary" + ); + + // CR 608.2c: priority passes resolve the real triggered-ability stack entry, + // executing Draw before its conditional DealDamage child. + runner.advance_until_stack_empty(); + assert!( + runner.state().stack.is_empty(), + "Fevered Visions trigger must fully resolve" + ); + + ( + hand_count(&runner, active_player) as isize - hand_before as isize, + runner.life(active_player) - life_before, + ) +} + +#[test] +fn controller_draws_to_four_without_taking_damage() { + let (draw_delta, life_delta) = resolve_fevered_end_step(P0, 3); + assert_eq!(draw_delta, 1, "the controller must draw before the gate"); + assert_eq!( + life_delta, 0, + "the controller is not their own opponent and must take no damage" + ); +} + +#[test] +fn opponent_draws_to_four_then_takes_two_damage() { + let (draw_delta, life_delta) = resolve_fevered_end_step(P1, 3); + assert_eq!(draw_delta, 1, "the opponent must draw before the gate"); + assert_eq!( + life_delta, -2, + "the post-draw hand of four must satisfy the damage gate" + ); +} + +#[test] +fn opponent_draws_to_three_without_taking_damage() { + let (draw_delta, life_delta) = resolve_fevered_end_step(P1, 2); + assert_eq!( + draw_delta, 1, + "the opponent must still draw below threshold" + ); + assert_eq!( + life_delta, 0, + "the post-draw hand of three must fail the damage gate" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 621d6f14a3..7bb06fafd4 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -164,6 +164,7 @@ mod eyetwitch_learn_decline_lesson; mod fact_or_fiction_pile_separation; mod fateful_handoff_target_mana_value_draw; mod festival_of_embers_graveyard_additional_cost; +mod fevered_visions; mod fewer_than_existential_threshold; mod field_marshal_soldier_anthem_first_strike; mod field_of_ruin_search; diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index 82b1887fcf..09003d9698 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -3,8 +3,8 @@ Consolidated from 50 per-batch clustering passes over the whole card database. Synonymous per-batch clusters were merged into canonical root causes, their card lists unioned and deduped, and ranked by total card appearances (largest first). - **Canonical root causes:** 30 -- **Distinct cards implicated:** 4734 -- **Total card appearances across root causes:** 4768 (a card may appear under more than one root cause when it exhibits multiple distinct misparses) +- **Distinct cards implicated:** 4733 +- **Total card appearances across root causes:** 4767 (a card may appear under more than one root cause when it exhibits multiple distinct misparses) This is the prioritized "fix N root causes → unlock M cards" backlog: the top handful of root causes account for the majority of broken cards. @@ -13,7 +13,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top | # | Root cause | # cards | Fix hint (where it likely lives) | |---|------------|--------:|----------------------------------| | 1 | Relative-clause / filter restriction on target dropped | 746 | oracle_target.rs / game/filter.rs — extend TargetFilter property extraction for trailing relative clauses | -| 2 | Dropped intervening-if / gating condition (condition: null) | 591 | oracle_nom/condition.rs parse_inner_condition — trigger/static parsers must delegate condition extraction here | +| 2 | Dropped intervening-if / gating condition (condition: null) | 590 | oracle_nom/condition.rs parse_inner_condition — trigger/static parsers must delegate condition extraction here | | 3 | Anaphor bound to wrong referent | 404 | oracle_quantity.rs context-ref resolution + game/ability_utils.rs forward_result wiring | | 4 | Conjoined / chained second effect clause dropped | 387 | oracle.rs effect-chain composition — split on 'and'/'then'/sentence boundaries and build sub_ability chain | | 5 | Dropped 'for each' / dynamic count collapsed to Fixed | 330 | oracle_quantity.rs parse_for_each_clause / parse_quantity_ref — thread ForEach/ObjectCount into the effect count field | @@ -805,7 +805,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top -### 2. Dropped intervening-if / gating condition (condition: null) (591 cards) +### 2. Dropped intervening-if / gating condition (condition: null) (590 cards) **Signature.** Trigger/static/replacement/spell condition left null though Oracle has an 'if/while/as long as/unless' game-state gate; the effect resolves unconditionally (CR 603.4 / 608.2c). @@ -985,7 +985,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Feed the Infection - Festival - Feudkiller's Verdict -- Fevered Visions - Fiery Encore - Fight for the Throne - Filigree Fracture