From 3d7672511ca1c7a8ff566677759d2ec2e8c4a663 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 16:52:30 +0000 Subject: [PATCH 1/2] Fix Lictor opponent-scoped "entered this turn" intervening-if MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lictor's 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" — dropped its intervening-"if" condition (parsed to None), so the ETB trigger fired unconditionally and the token was created every time Lictor entered. The "under your control" surface of this class was already supported; this adds the opponent-scoped, past-tense mirror. A new `parse_entered_this_turn_under_opponent_control` combinator reuses the shared `parse_or_more_entered_count` / `parse_entered_this_turn_subject` helpers and carries the scope via `PlayerScope::Opponent { Max }` — the existential "an opponent" reading already documented on `parse_opponent_had_entered_this_turn` — over the CR 608.2i `BattlefieldEntriesThisTurn` snapshot. No new engine variant. The opponent surface in the QUANTITY path stays honestly Unimplemented (no printed card); only the condition path, which Lictor uses, is added. Adds parser unit tests (singular + count forms) and a discriminating runtime test that casts Lictor and asserts the token is gated on an opponent's entry (fails on revert). Removes Lictor from the parser misparse backlog. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Gw63XPowSxhpJaS9xM8Xyj --- .../engine/src/parser/oracle_nom/condition.rs | 94 +++++++++++ .../lictor_opponent_entered_this_turn.rs | 155 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + docs/parser-misparse-backlog.md | 9 +- 4 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index 0b961a5957..c676b42c69 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,66 @@ 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 { .. }, + 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 { .. }, + .. + }, + }, + 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..50bdf13b5a --- /dev/null +++ b/crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs @@ -0,0 +1,155 @@ +//! 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::{ + 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 { .. }, + 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 81a3ae2ee2..eaa6a6139f 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -645,6 +645,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 From 03e8046914a7b1689654e8bdef0563fa0e647d47 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 23 Jul 2026 13:40:57 -0700 Subject: [PATCH 2/2] test(PR-6554): pin opponent aggregate to Max in Lictor shape assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three shape assertions matched `PlayerScope::Opponent { .. }`, which also admits `AggregateFunction::Sum` — precisely the cross-opponent summation the combinator's doc comment identifies as wrong and that is only observable at three or more seats. Every test the PR adds would still have passed if the parser were later changed to emit `Sum`. Tighten all three sites to `{ aggregate: AggregateFunction::Max }` so the existential "an opponent" reading is actually pinned by the test suite. Co-authored-by: keloide <75585494+keloide@users.noreply.github.com> --- crates/engine/src/parser/oracle_nom/condition.rs | 10 ++++++++-- .../integration/lictor_opponent_entered_this_turn.rs | 8 ++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index c676b42c69..8ca48ecd5b 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -12570,7 +12570,10 @@ mod tests { QuantityExpr::Ref { qty: QuantityRef::BattlefieldEntriesThisTurn { - player: PlayerScope::Opponent { .. }, + player: + PlayerScope::Opponent { + aggregate: AggregateFunction::Max, + }, filter: TargetFilter::Typed(filter), }, }, @@ -12600,7 +12603,10 @@ mod tests { QuantityExpr::Ref { qty: QuantityRef::BattlefieldEntriesThisTurn { - player: PlayerScope::Opponent { .. }, + player: + PlayerScope::Opponent { + aggregate: AggregateFunction::Max, + }, .. }, }, diff --git a/crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs b/crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs index 50bdf13b5a..d38ec0cf43 100644 --- a/crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs +++ b/crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs @@ -18,7 +18,8 @@ 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::{ - Comparator, PlayerScope, QuantityExpr, QuantityRef, TargetFilter, TriggerCondition, TypeFilter, + AggregateFunction, Comparator, PlayerScope, QuantityExpr, QuantityRef, TargetFilter, + TriggerCondition, TypeFilter, }; use engine::types::identifiers::ObjectId; use engine::types::phase::Phase; @@ -82,7 +83,10 @@ fn lictor_condition_is_opponent_scoped_entry_tally() { QuantityExpr::Ref { qty: QuantityRef::BattlefieldEntriesThisTurn { - player: PlayerScope::Opponent { .. }, + player: + PlayerScope::Opponent { + aggregate: AggregateFunction::Max, + }, filter: TargetFilter::Typed(f), }, },