diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index 0b961a5957..8ca48ecd5b 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -304,6 +304,8 @@ fn parse_event_history_conditions(input: &str) -> OracleResult<'_, StaticConditi parse_entered_this_turn, // CR 102.2 + CR 608.2h: opponent-scoped entry tally (Zendikar trap cycle). parse_opponent_had_entered_this_turn, + // CR 102.2 + CR 603.4: opponent-scoped past-tense entry gate (Lictor). + parse_entered_this_turn_under_opponent_control, parse_opponent_cast_spell_this_turn, parse_youve_this_turn, parse_first_spell_this_game_condition, @@ -7811,6 +7813,38 @@ fn parse_opponent_had_entered_this_turn(input: &str) -> OracleResult<'_, StaticC parse_entered_this_turn_subject(rest, suffix, 1, player) } +/// CR 102.2 + CR 102.3 + CR 603.4 + CR 608.2h + CR 608.2i: "[a | an | another | N +/// or more] entered the battlefield under an opponent's control this turn" +/// — the opponent-scoped, PAST-tense mirror of `parse_entered_this_turn`'s "under +/// your control" surface (Lictor's Pheromone Trail intervening-"if"). Distinct +/// from `parse_opponent_had_entered_this_turn`, which reads the "an opponent had … +/// enter … under their control" auxiliary/present-tense surface of the Zendikar +/// trap cycle; this is the bare subject-first past-tense form that leads with the +/// type rather than an "an opponent had" prefix. +/// +/// The "under an opponent's control" scope is carried by +/// `PlayerScope::Opponent { aggregate: Max }` — the existential "an opponent" +/// reading documented on `parse_opponent_had_entered_this_turn` — NOT a +/// `controller: Opponent` injected into the type filter: the runtime keys the +/// `BattlefieldEntriesThisTurn` tally on `record.controller` per opponent and +/// takes the largest, so in a multiplayer game the per-opponent count is compared +/// to the threshold rather than the cross-opponent sum (two different opponents +/// each having one creature enter must NOT satisfy "two or more … under an +/// opponent's control"). CR 608.2i keeps a permanent that has since left the +/// battlefield counted, because the snapshot survives departure. +fn parse_entered_this_turn_under_opponent_control( + input: &str, +) -> OracleResult<'_, StaticCondition> { + let suffix = "entered the battlefield under an opponent's control this turn"; + let player = PlayerScope::Opponent { + aggregate: AggregateFunction::Max, + }; + if let Ok(result) = parse_or_more_entered_count(input, suffix, player.clone()) { + return Ok(result); + } + parse_entered_this_turn_subject(input, suffix, 1, player) +} + /// Parse "there are [fewer than/more than] N [or more] [things] ..." conditions. /// /// Covers threshold ("seven or more cards"), delirium ("four or more card types"), @@ -12519,6 +12553,72 @@ mod tests { } } + #[test] + fn test_entered_this_turn_under_opponent_control_singular() { + // Lictor's Pheromone Trail intervening-"if". The "under an opponent's + // control" scope must land on PlayerScope::Opponent (existential Max), + // NOT a controller injected into the type filter, and the filter must + // still carry the creature type restriction. + let (rest, c) = parse_inner_condition( + "a creature entered the battlefield under an opponent's control this turn", + ) + .unwrap(); + assert_eq!(rest, ""); + match c { + StaticCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::BattlefieldEntriesThisTurn { + player: + PlayerScope::Opponent { + aggregate: AggregateFunction::Max, + }, + filter: TargetFilter::Typed(filter), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + } => { + assert_eq!(filter.controller, None); + assert!(filter.type_filters.contains(&TypeFilter::Creature)); + } + other => { + panic!("expected opponent-scoped BattlefieldEntriesThisTurn GE 1, got {other:?}") + } + } + } + + #[test] + fn test_entered_this_turn_under_opponent_control_count() { + // The counted threshold surface routes through the same opponent scope. + let (rest, c) = parse_inner_condition( + "two or more creatures entered the battlefield under an opponent's control this turn", + ) + .unwrap(); + assert_eq!(rest, ""); + match c { + StaticCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::BattlefieldEntriesThisTurn { + player: + PlayerScope::Opponent { + aggregate: AggregateFunction::Max, + }, + .. + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 2 }, + } => {} + other => { + panic!("expected opponent-scoped BattlefieldEntriesThisTurn GE 2, got {other:?}") + } + } + } + #[test] fn test_you_had_another_enter_this_turn() { let (rest, c) = parse_inner_condition( diff --git a/crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs b/crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs new file mode 100644 index 0000000000..d38ec0cf43 --- /dev/null +++ b/crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs @@ -0,0 +1,159 @@ +//! Lictor (Warhammer 40,000 Commander) — Pheromone Trail: +//! "When this creature enters, if a creature entered the battlefield under an +//! opponent's control this turn, create a 3/3 green Tyranid Warrior creature +//! token with trample." +//! +//! Regression for the dropped opponent-scoped "entered … under an opponent's +//! control this turn" intervening-"if" (CR 603.4). Before the fix the condition +//! parsed to `None`, so the ETB trigger fired UNCONDITIONALLY and Lictor made a +//! token every time it entered — even with no opponent entry that turn. +//! +//! The "under your control" surface of this class was already supported; this +//! adds the opponent-scoped past-tense mirror, carried by +//! `PlayerScope::Opponent { Max }` (the existential "an opponent" reading, per +//! `parse_opponent_had_entered_this_turn`) over the CR 608.2i +//! `BattlefieldEntriesThisTurn` snapshot. + +use engine::game::restrictions::record_battlefield_entry; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::parser::parse_oracle_text; +use engine::types::ability::{ + AggregateFunction, Comparator, PlayerScope, QuantityExpr, QuantityRef, TargetFilter, + TriggerCondition, TypeFilter, +}; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; + +const LICTOR: &str = + "Flash\nPheromone Trail — When this creature enters, if a creature entered the \ +battlefield under an opponent's control this turn, create a 3/3 green Tyranid Warrior creature \ +token with trample."; + +/// Stamp `id` into the production battlefield-entry ledger for the current turn, +/// exactly as `record_zone_change` does in a real game. +fn record_entry_now(runner: &mut GameRunner, id: ObjectId) { + let turn = runner.state().turn_number; + record_battlefield_entry(runner.state_mut(), id); + runner + .state_mut() + .objects + .get_mut(&id) + .unwrap() + .entered_battlefield_turn = Some(turn); +} + +/// Count battlefield Tyranid Warrior tokens (Lictor's Pheromone Trail output). +fn tyranid_warrior_count(runner: &GameRunner) -> usize { + runner + .state() + .battlefield + .iter() + .filter(|id| { + runner + .state() + .objects + .get(id) + .is_some_and(|o| o.is_token && o.name == "Tyranid Warrior") + }) + .count() +} + +/// Parse-level shape lock: the intervening-"if" must lower to the opponent-scoped +/// `BattlefieldEntriesThisTurn` comparison, NOT a dropped `None`. +/// +/// REVERT-PROBE: remove `parse_entered_this_turn_under_opponent_control` and the +/// condition returns to `None`, panicking here. +#[test] +fn lictor_condition_is_opponent_scoped_entry_tally() { + let parsed = parse_oracle_text( + LICTOR, + "Lictor", + &[], + &["Creature".to_string()], + &["Tyranid".to_string()], + ); + let trigger = parsed + .triggers + .iter() + .find(|t| t.condition.is_some()) + .expect("Lictor's ETB must carry an intervening-if condition, not a dropped None"); + match trigger.condition.as_ref().unwrap() { + TriggerCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::BattlefieldEntriesThisTurn { + player: + PlayerScope::Opponent { + aggregate: AggregateFunction::Max, + }, + filter: TargetFilter::Typed(f), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + } => { + assert_eq!(f.controller, None, "controller lives on the PlayerScope"); + assert!( + f.type_filters.contains(&TypeFilter::Creature), + "the creature restriction must survive, got {:?}", + f.type_filters + ); + } + other => panic!("expected opponent-scoped BattlefieldEntriesThisTurn GE 1, got {other:?}"), + } +} + +/// Positive: an opponent's creature entered this turn ⇒ Pheromone Trail fires. +#[test] +fn lictor_makes_token_when_opponent_creature_entered() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let lictor = scenario + .add_creature_to_hand_from_oracle(P0, "Lictor", 2, 3, LICTOR) + .id(); + // An opponent (P1) creature that entered the battlefield this turn. + let opp_creature = scenario.add_creature(P1, "Opponent Entrant", 2, 2).id(); + let mut runner = scenario.build(); + record_entry_now(&mut runner, opp_creature); + + runner.cast(lictor).resolve(); + runner.advance_until_stack_empty(); + + assert_eq!( + tyranid_warrior_count(&runner), + 1, + "CR 603.4: the intervening-if is TRUE (an opponent's creature entered this \ + turn), so Pheromone Trail creates a Tyranid Warrior" + ); +} + +/// Negative discriminator: only Lictor itself entered (under P0's control), so no +/// opponent entry exists ⇒ Pheromone Trail must NOT fire. +/// +/// REVERT-PROBE: with the condition dropped to `None` the trigger fires +/// unconditionally and this reads 1 token, FAIL. This is the load-bearing +/// assertion — it fails on the unfixed engine. +#[test] +fn lictor_makes_no_token_without_opponent_entry() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let lictor = scenario + .add_creature_to_hand_from_oracle(P0, "Lictor", 2, 3, LICTOR) + .id(); + // A P0 creature that entered this turn — under YOUR control, not an + // opponent's — plus Lictor's own entry. Neither satisfies the opponent scope. + let own_creature = scenario.add_creature(P0, "Own Entrant", 2, 2).id(); + let mut runner = scenario.build(); + record_entry_now(&mut runner, own_creature); + + runner.cast(lictor).resolve(); + runner.advance_until_stack_empty(); + + assert_eq!( + tyranid_warrior_count(&runner), + 0, + "CR 603.4: no creature entered under an OPPONENT's control this turn, so \ + the intervening-if is FALSE and no token is created" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 621d6f14a3..ced8ba3973 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -647,6 +647,7 @@ mod landing_zone_this_way_quantity; mod lathiel_end_step_counters_repro; mod leeching_sliver; mod leyline_taps_for_mana_repro; +mod lictor_opponent_entered_this_turn; mod lightning_dart_disjunctive_color_instead; mod liliana_dreadhorde_multi_dies; mod liliana_waker_cross_scope_decline; diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index 82b1887fcf..685bad99c4 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). @@ -1115,7 +1115,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Lethal Throwdown - Liberating Combustion - Liberator, Urza's Battlethopter -- Lictor - Lifecraft Awakening - Lighthouse Chronologist - Lightning Dart