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
100 changes: 100 additions & 0 deletions crates/engine/src/parser/oracle_nom/condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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] <type> 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"),
Expand Down Expand Up @@ -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:?}")
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[test]
fn test_you_had_another_enter_this_turn() {
let (rest, c) = parse_inner_condition(
Expand Down
159 changes: 159 additions & 0 deletions crates/engine/tests/integration/lictor_opponent_entered_this_turn.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 4 additions & 5 deletions docs/parser-misparse-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 |
Expand Down Expand Up @@ -805,7 +805,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top

</details>

### 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).

Expand Down Expand Up @@ -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
Expand Down
Loading