Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
54 changes: 53 additions & 1 deletion crates/engine/src/parser/oracle_effect/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8508,7 +8508,24 @@ pub(crate) fn parse_where_x_quantity_expression(where_x_expression: &str) -> Opt
// CDA-quantity classification takes precedence: it is the more specific
// where-X interpreter (object counts, "that spell's mana value",
// "the number of age counters on this enchantment", etc.).
if let Some(expr) = parse_cda_quantity(where_x_expression) {
//
// CR 109.5 + CR 608.2c + CR 603.2b/CR 102.1: third-person player anaphors
// inside a where-X definition ("they control", "that player controls") bind
// to the contextually-scoped player, exactly as the sibling for-each
// interpreter does (parse_for_each_clause_with_context). `ScopedPlayer`
// degrades to the source's controller when no scope is stamped at runtime
// (scoped_player_or_controller / resolve_player_for_context_ref), so
// caster-relative reads are unchanged for spells, while each-player phase
// triggers (Citadel of Pain) read the phase player CR-correctly. "you
// control" is ctx-independent and unaffected.
let mut anaphor_ctx = crate::parser::oracle_quantity::for_each_anaphor_context(
&ParseContext::default(),
&ControllerRef::ScopedPlayer,
);
if let Some(expr) = crate::parser::oracle_quantity::parse_cda_quantity_with_context(
where_x_expression,
&mut anaphor_ctx,
) {
return Some(expr);
}
// CR 107.3i + CR 115.1: Some where-X definitions spell the count as
Expand Down Expand Up @@ -11705,6 +11722,41 @@ mod where_x_tests {
);
}

/// Issue #6508: a where-X filter-controller anaphor ("they control") inside a
/// trigger body must bind to the scoped player, mirroring the sibling
/// for-each interpreter (CR 608.2c). `parse_where_x_quantity_expression` now
/// carries the `ScopedPlayer` anaphor context into the CDA-quantity delegate,
/// so Citadel of Pain's "the number of untapped lands they control" counts
/// the phase player's untapped lands.
#[test]
fn where_x_they_control_binds_scoped_player() {
let parsed = parse_where_x_quantity_expression("the number of untapped lands they control");
let Some(QuantityExpr::Ref {
qty: QuantityRef::ObjectCount { filter },
}) = parsed
else {
panic!("expected an object count, got {parsed:?}");
};
let TargetFilter::Typed(typed) = filter else {
panic!("expected a typed object-count filter, got {filter:?}");
};
assert_eq!(
typed.controller,
Some(ControllerRef::ScopedPlayer),
"\"they control\" must bind to the scoped player"
);
assert!(
typed.type_filters.contains(&TypeFilter::Land),
"expected Land in the object-count filter, got {:?}",
typed.type_filters
);
assert!(
typed.properties.contains(&FilterProp::Untapped),
"expected the Untapped qualifier, got {:?}",
typed.properties
);
}

/// CR 107.3i + CR 202.3: the where-X traversal rebinds a `TotalManaValue`
/// target constraint's `Variable("X")` cap to the die-result
/// `EventContextAmount` (Ancient Brass Dragon's "where X is the result").
Expand Down
5 changes: 4 additions & 1 deletion crates/engine/src/parser/oracle_quantity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3334,7 +3334,10 @@ fn parse_for_each_clause_with_they_controller(
None
}

fn for_each_anaphor_context(ctx: &ParseContext, they_controller: &ControllerRef) -> ParseContext {
pub(crate) fn for_each_anaphor_context(
ctx: &ParseContext,
they_controller: &ControllerRef,
) -> ParseContext {
ParseContext {
relative_player_scope: Some(they_controller.clone()),
subject: ctx.subject.clone(),
Expand Down
14 changes: 14 additions & 0 deletions crates/engine/src/parser/oracle_trigger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1636,6 +1636,20 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition {
crate::parser::oracle_effect::rewrite_player_quantity_refs_to_source_chosen(ability);
}
}
// CR 603.2b + CR 102.1: each-player/each-opponent PHASE triggers bind
// "their hand"/"their life" possessives to the phase's active player, which
// the runtime stamps onto `scoped_player` (build_triggered_ability,
// game/triggers.rs). Reuses the identical rewrite the TargetPlayer event
// triggers use; `PlayerScope::Controller` ("your hand") is deliberately NOT
// rewritten by that pass, so mixed-anaphor cards (Dark Suspicions) keep the
// controller side intact. Mutually exclusive with the SourceChosenPlayer
// branch above: `relative_player_scope_for_condition` checks the chosen-player
// phase before the scoped-phase player, so The Rack never enters here.
if modifiers.relative_player_scope == Some(ControllerRef::ScopedPlayer) {
if let Some(ability) = execute.as_deref_mut() {
crate::parser::oracle_effect::rewrite_event_player_quantity_refs_to_scoped(ability);
}
}
if let Some(ability) = execute.as_deref_mut() {
rewrite_each_other_player_scope_for_any_caster_spell_triggers(
&def,
Expand Down
234 changes: 234 additions & 0 deletions crates/engine/src/parser/oracle_trigger_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17511,6 +17511,240 @@ fn phase_trigger_blinkmoth_urn_that_player_adds_mana_for_their_artifacts() {
}
}

/// Issue #6508 SHAPE — Citadel of Pain: "At the beginning of each player's end
/// step, this enchantment deals X damage to that player, where X is the number
/// of untapped lands they control." The where-X filter-controller anaphor
/// ("they control") must bind to the scoped (phase) player, not the source
/// controller. CR 608.2c: a per-player-scoped count reads the iterating player.
#[test]
fn citadel_of_pain_each_player_end_step_scoped_amount() {
let def = parse_trigger_line(
"At the beginning of each player's end step, this enchantment deals X damage to that player, where X is the number of untapped lands they control.",
"Citadel of Pain",
);
assert_eq!(def.mode, TriggerMode::Phase);
assert_eq!(def.phase, Some(Phase::End));
assert_eq!(def.constraint, None);
let exec = def
.execute
.as_ref()
.expect("Citadel of Pain must have execute");
match exec.effect.as_ref() {
Effect::DealDamage { amount, target, .. } => {
assert_eq!(
*target,
TargetFilter::ScopedPlayer,
"damage recipient must be the phase player (ScopedPlayer)"
);
let QuantityExpr::Ref {
qty:
QuantityRef::ObjectCount {
filter: TargetFilter::Typed(tf),
},
} = amount
else {
panic!("expected ObjectCount amount, got {amount:?}");
};
assert!(
tf.type_filters.contains(&TypeFilter::Land),
"count must be lands, got {:?}",
tf.type_filters
);
assert!(
tf.properties.contains(&FilterProp::Untapped),
"count must be UNTAPPED lands, got {:?}",
tf.properties
);
assert_eq!(
tf.controller,
Some(ControllerRef::ScopedPlayer),
"\"they control\" must bind to the scoped player (CR 608.2c)"
);
}
other => panic!("expected Effect::DealDamage, got {other:?}"),
}
}

/// Issue #6508 SHAPE (Part B) — Iron Maiden: "At the beginning of each
/// opponent's upkeep, this artifact deals X damage to that player, where X is
/// the number of cards in their hand minus 4." The possessive hand-count
/// ("their hand") is a context-free `TargetZoneCardCount` at parse time; the
/// scoped-phase-trigger lowering rewrites it to `HandSize { ScopedPlayer }`
/// (CR 603.2b + CR 102.1). The `minus 4` offset is preserved.
#[test]
fn iron_maiden_each_opponent_upkeep_scoped_hand_size() {
let def = parse_trigger_line(
"At the beginning of each opponent's upkeep, this artifact deals X damage to that player, where X is the number of cards in their hand minus 4.",
"Iron Maiden",
);
assert_eq!(def.mode, TriggerMode::Phase);
assert_eq!(def.phase, Some(Phase::Upkeep));
let exec = def.execute.as_ref().expect("Iron Maiden must have execute");
match exec.effect.as_ref() {
Effect::DealDamage { amount, target, .. } => {
assert_eq!(*target, TargetFilter::ScopedPlayer);
let QuantityExpr::Offset { inner, offset } = amount else {
panic!("expected Offset amount, got {amount:?}");
};
assert_eq!(*offset, -4, "the \"minus 4\" offset must be preserved");
assert_eq!(
**inner,
QuantityExpr::Ref {
qty: QuantityRef::HandSize {
player: PlayerScope::ScopedPlayer,
},
},
"\"cards in their hand\" must bind to the scoped player"
);
}
other => panic!("expected Effect::DealDamage, got {other:?}"),
}
}

/// Issue #6508 SHAPE (multi-authority, Part B) — Dark Suspicions: "At the
/// beginning of each opponent's upkeep, that player loses X life, where X is the
/// number of cards in that player's hand minus the number of cards in your
/// hand." The scoped-player hand-count moves to `ScopedPlayer` while the
/// controller-side "your hand" MUST stay `Controller` (CR 109.5) — the rewrite
/// touches only the Target/possessive side, never `You`.
#[test]
fn dark_suspicions_scoped_hand_minus_controller_hand() {
let def = parse_trigger_line(
"At the beginning of each opponent's upkeep, that player loses X life, where X is the number of cards in that player's hand minus the number of cards in your hand.",
"Dark Suspicions",
);
assert_eq!(def.mode, TriggerMode::Phase);
assert_eq!(def.phase, Some(Phase::Upkeep));
let exec = def
.execute
.as_ref()
.expect("Dark Suspicions must have execute");
match exec.effect.as_ref() {
Effect::LoseLife { amount, .. } => {
let QuantityExpr::Sum { exprs } = amount else {
panic!("expected Sum amount, got {amount:?}");
};
assert_eq!(
exprs.len(),
2,
"sum of scoped-player hand and negated controller hand, got {exprs:?}"
);
assert_eq!(
exprs[0],
QuantityExpr::Ref {
qty: QuantityRef::HandSize {
player: PlayerScope::ScopedPlayer,
},
},
"\"that player's hand\" must bind to the scoped player"
);
assert_eq!(
exprs[1],
QuantityExpr::Multiply {
factor: -1,
inner: Box::new(QuantityExpr::Ref {
qty: QuantityRef::HandSize {
player: PlayerScope::Controller,
},
}),
},
"\"your hand\" must remain Controller (CR 109.5) — the rewrite must not move it"
);
}
other => panic!("expected Effect::LoseLife, got {other:?}"),
}
}

/// Issue #6508 SHAPE (REQUIRED — only exerciser of the `PlayerScope::Target →
/// ScopedPlayer` life-total arm) — Havoc Festival: "At the beginning of each
/// player's upkeep, that player loses half their life, rounded up." The
/// life-total possessive ("their life") must bind to the scoped player
/// (CR 603.2b + CR 102.1). Reach-guard: the loss is a parsed `DivideRounded`
/// (not `Unimplemented`).
#[test]
fn havoc_festival_life_total_binds_scoped_player() {
let def = parse_trigger_line(
"At the beginning of each player's upkeep, that player loses half their life, rounded up.",
"Havoc Festival",
);
assert_eq!(def.mode, TriggerMode::Phase);
assert_eq!(def.phase, Some(Phase::Upkeep));
let exec = def
.execute
.as_ref()
.expect("Havoc Festival must have execute");
match exec.effect.as_ref() {
Effect::LoseLife { amount, .. } => {
let QuantityExpr::DivideRounded {
inner,
divisor,
rounding,
} = amount
else {
panic!(
"reach-guard failed: expected parsed DivideRounded life loss, got {amount:?}"
);
};
assert_eq!(*divisor, 2);
assert_eq!(*rounding, crate::types::ability::RoundingMode::Up);
assert_eq!(
**inner,
QuantityExpr::Ref {
qty: QuantityRef::LifeTotal {
player: PlayerScope::ScopedPlayer,
},
},
"\"their life\" must bind to the scoped player (PlayerScope::Target → ScopedPlayer)"
);
}
other => panic!("expected Effect::LoseLife, got {other:?}"),
}
}

/// Issue #6508 negative (with reach-guard) — "you control" inside a where-X of
/// an each-player phase trigger must STAY bound to the controller (You),
/// unaffected by the scoped-player anaphor fix (CR 109.5). Reach-guard: the
/// recipient is still `ScopedPlayer` and the amount is a real parsed
/// `ObjectCount` (not `Unimplemented`), proving the where-X actually parsed and
/// the assertion is not vacuously true.
#[test]
fn each_player_end_step_where_x_you_control_stays_you() {
let def = parse_trigger_line(
"At the beginning of each player's end step, this enchantment deals X damage to that player, where X is the number of creatures you control.",
"Test Card",
);
let exec = def.execute.as_ref().expect("must have execute");
match exec.effect.as_ref() {
Effect::DealDamage { amount, target, .. } => {
assert_eq!(
*target,
TargetFilter::ScopedPlayer,
"reach-guard: the recipient is still the scoped player"
);
let QuantityExpr::Ref {
qty:
QuantityRef::ObjectCount {
filter: TargetFilter::Typed(tf),
},
} = amount
else {
panic!("reach-guard failed: expected a parsed ObjectCount, got {amount:?}");
};
assert!(
tf.type_filters.contains(&TypeFilter::Creature),
"count must be creatures, got {:?}",
tf.type_filters
);
assert_eq!(
tf.controller,
Some(ControllerRef::You),
"\"you control\" must remain You (CR 109.5)"
);
}
other => panic!("expected Effect::DealDamage, got {other:?}"),
}
}

#[test]
fn trigger_each_of_your_main_phases_uses_main_phase_constraint() {
let def = parse_trigger_line(
Expand Down
Loading
Loading