Skip to content
Open
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
13 changes: 13 additions & 0 deletions crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,19 @@ fn restriction_scope_matches_player(
debug_assert!(false, "ScopedPlayer should be resolved by add_restriction");
false
}
RestrictionPlayerScope::ParentObjectTargetController => {
// CR 109.4: normally resolved to `SpecificPlayer` by `add_restriction`
// (via `parent_target_controller`) when the restriction is created.
// Unlike the always-resolved sibling scopes (`TargetedPlayer`,
// `ScopedPlayer`), this one can legitimately remain unresolved when
// there is no object referent — a malformed or hostile state, proven
// reachable by `add_restriction`'s
// `parent_object_target_controller_unresolved_without_object_target`.
// That is a genuine fail-closed outcome (restrict no one), NOT a bug,
// so this arm must return `false` rather than `debug_assert!(false)` —
// a debug/test panic here would break the documented fail-closed path.
false
}
RestrictionPlayerScope::OpponentsOfSourceController => {
source_controller.is_some_and(|controller| controller != caster)
}
Expand Down
6 changes: 4 additions & 2 deletions crates/engine/src/game/combat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2706,8 +2706,10 @@ pub fn declare_attackers_with_bands(
crate::types::ability::RestrictionPlayerScope::TargetedPlayer
| crate::types::ability::RestrictionPlayerScope::ParentTargetedPlayer
| crate::types::ability::RestrictionPlayerScope::DefendingPlayer
// CR 109.5: resolved to `SpecificPlayer` by `add_restriction` at
// creation time, so an unresolved scope here restricts no one.
// CR 109.4 + CR 109.5: resolved to `SpecificPlayer` by
// `add_restriction` at creation time, so an unresolved scope here
// restricts no one.
| crate::types::ability::RestrictionPlayerScope::ParentObjectTargetController
| crate::types::ability::RestrictionPlayerScope::ScopedPlayer => false,
};
if !attacker_is_affected {
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/game/derived_views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -691,8 +691,11 @@ fn restriction_affected_players(
// CR 109.5: `add_restriction` resolves the scoped player to
// `SpecificPlayer` when the restriction is created, so a stored
// restriction never carries an unresolved placeholder scope here.
// CR 109.4: `ParentObjectTargetController` is likewise resolved to
// `SpecificPlayer` by `add_restriction` at creation time.
RestrictionPlayerScope::TargetedPlayer
| RestrictionPlayerScope::ParentTargetedPlayer
| RestrictionPlayerScope::ParentObjectTargetController
| RestrictionPlayerScope::ScopedPlayer => Vec::new(),
// CR 508.5a: `add_restriction` resolves the defending player to
// `SpecificPlayer` when the restriction is created, so a stored
Expand Down
110 changes: 110 additions & 0 deletions crates/engine/src/game/effects/add_restriction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@ fn fill_runtime_fields(
ability.scoped_player.unwrap_or(ability.controller),
);
}
// CR 109.4 + CR 608.2c + CR 608.2h: "its controller" — capture the
// controller of the parent object target (the countered spell) as
// the restriction is created. By now the spell has left the stack
// (countered), so parent_target_controller reads it from
// last-known information. If the referent can't be resolved (no
// parent object target), leave the scope unresolved — enforcement
// then restricts no one (fail-closed).
// NOTE: the owner!=controller LKI branch (CR 608.2h) — countering
// an opponent-OWNED spell — is not exercised by the primary
// integration fixture (a player countering their own-owned spell).
RestrictionPlayerScope::ParentObjectTargetController => {
if let Some(controller) =
crate::game::ability_utils::parent_target_controller(ability, state)
{
*affected_players = RestrictionPlayerScope::SpecificPlayer(controller);
}
}
RestrictionPlayerScope::AllPlayers
| RestrictionPlayerScope::SpecificPlayer(_)
| RestrictionPlayerScope::OpponentsOfSourceController => {}
Expand Down Expand Up @@ -359,6 +376,99 @@ mod tests {
));
}

/// CR 109.4 + CR 608.2h: Render Silent — "its controller" (the controller of
/// the parent object target, the countered spell) lowers to the object's
/// controller. The parent Counter target is inherited onto the sub-ability, so
/// the restriction's `ParentObjectTargetController` scope resolves via
/// `parent_target_controller` to that object's controller (PlayerId(1)), NOT
/// the ability controller (PlayerId(0)).
#[test]
fn parent_object_target_controller_resolves_to_object_controller() {
use crate::game::zones::create_object;
let mut state = GameState::new_two_player(42);

// The countered spell has landed in a graveyard (CR 701.6a) controlled by
// PlayerId(1); create_object leaves controller == owner, which is the
// last-known controller of the countered spell.
let spell_obj = create_object(
&mut state,
crate::types::identifiers::CardId(1),
PlayerId(1),
"Some Spell".to_string(),
Zone::Graveyard,
);

let ability = ResolvedAbility::new(
Effect::AddRestriction {
restriction: GameRestriction::ProhibitActivity {
source: ObjectId(0),
affected_players: RestrictionPlayerScope::ParentObjectTargetController,
expiry: RestrictionExpiry::EndOfTurn,
activity: ProhibitedActivity::CastSpells { spell_filter: None },
},
},
// The Counter's object target inherited onto the restriction sub-ability.
vec![TargetRef::Object(spell_obj)],
ObjectId(7),
PlayerId(0),
);

let mut events = Vec::new();
resolve(&mut state, &ability, &mut events).unwrap();

assert!(
matches!(
&state.restrictions[0],
GameRestriction::ProhibitActivity {
source: ObjectId(7),
affected_players: RestrictionPlayerScope::SpecificPlayer(PlayerId(1)),
activity: ProhibitedActivity::CastSpells { spell_filter: None },
..
}
),
"got {:?}",
state.restrictions[0]
);
}

/// CR 109.4: hostile — a `ParentObjectTargetController` restriction with no
/// parent object target cannot resolve its referent, so it stays unresolved
/// (fail-closed: enforcement then restricts no one) rather than defaulting to
/// the ability controller.
#[test]
fn parent_object_target_controller_unresolved_without_object_target() {
let mut state = GameState::new_two_player(42);

let ability = ResolvedAbility::new(
Effect::AddRestriction {
restriction: GameRestriction::ProhibitActivity {
source: ObjectId(0),
affected_players: RestrictionPlayerScope::ParentObjectTargetController,
expiry: RestrictionExpiry::EndOfTurn,
activity: ProhibitedActivity::CastSpells { spell_filter: None },
},
},
vec![],
ObjectId(7),
PlayerId(0),
);

let mut events = Vec::new();
resolve(&mut state, &ability, &mut events).unwrap();

assert!(
matches!(
&state.restrictions[0],
GameRestriction::ProhibitActivity {
affected_players: RestrictionPlayerScope::ParentObjectTargetController,
..
}
),
"got {:?}",
state.restrictions[0]
);
}

/// CR 109.5 + CR 514.2 + CR 500.7: The Second Doctor / City Hall — the
/// per-iteration `ScopedPlayer` restriction resolves to the scoped opponent,
/// and a "during their next turn" duration anchors its expiry on THAT
Expand Down
8 changes: 8 additions & 0 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2805,6 +2805,14 @@ fn try_parse_cant_cast_spells_effect(tp: TextPair<'_>) -> Option<ParsedEffectCla
RestrictionPlayerScope::ParentTargetedPlayer,
tag("that player"),
),
// CR 109.4 + CR 608.2c: "its controller" — the controller of the
// parent object target (the countered spell). Bound at resolution via
// add_restriction + parent_target_controller. The sub-ability inherits
// the counter's target at effects/mod.rs:7441-7450.
value(
RestrictionPlayerScope::ParentObjectTargetController,
tag("its controller"),
),
// CR 508.5: "defending player" — the player being attacked by the
// source ("Whenever ~ attacks, defending player can't cast spells
// this turn." — Xantid Swarm).
Expand Down
62 changes: 62 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25793,6 +25793,68 @@ fn cant_cast_spells_this_turn_defending_player() {
);
}

#[test]
fn render_silent_its_controller_cant_cast_spells() {
// CR 109.4 + CR 608.2c + CR 701.6: Render Silent (DGM) — "Counter target
// spell. Its controller can't cast spells this turn." The head is a counter;
// the chained sub-ability restricts the countered spell's CONTROLLER (an
// object-target-derived player anaphor), not a reused player target.
let def = parse_effect_chain(
"Counter target spell. Its controller can't cast spells this turn.",
AbilityKind::Spell,
);
// Head: Effect::Counter with a stack-spell target.
assert!(
matches!(
&*def.effect,
Effect::Counter {
target: TargetFilter::StackSpell,
..
}
),
"head got {:?}",
def.effect
);
// Sub-ability: "its controller can't cast spells this turn" lowers to an
// AddRestriction scoped to ParentObjectTargetController. If Step 2 (the parser
// scope arm) is reverted, try_parse_cant_cast_spells_effect returns None, the
// clause is swallowed into an Effect::Unimplemented, and this assertion fails.
let sub = def
.sub_ability
.as_deref()
.expect("counter must chain a restriction sub-ability");
assert!(
matches!(
&*sub.effect,
Effect::AddRestriction {
restriction: GameRestriction::ProhibitActivity {
affected_players: RestrictionPlayerScope::ParentObjectTargetController,
expiry: RestrictionExpiry::EndOfTurn,
activity: ProhibitedActivity::CastSpells { spell_filter: None },
..
}
}
),
"sub got {:?}",
sub.effect
);
// Discriminating guard: no Effect::Unimplemented anywhere in the parsed chain.
// A swallowed "this turn" tail or an unrecognized "its controller" subject
// would surface here as an Unimplemented sub-ability — the exact blind spot a
// `matches!(.., ..)` shape assertion would hide.
fn assert_no_unimplemented(def: &crate::types::ability::AbilityDefinition) {
assert!(
!matches!(&*def.effect, Effect::Unimplemented { .. }),
"unexpected Unimplemented effect: {:?}",
def.effect
);
if let Some(sub) = def.sub_ability.as_deref() {
assert_no_unimplemented(sub);
}
}
assert_no_unimplemented(&def);
}

#[test]
fn cant_cast_spells_during_that_players_next_turn() {
// CR 514.2 + CR 500.7: Sphinx's Decree / Azor — "Each opponent can't cast
Expand Down
13 changes: 13 additions & 0 deletions crates/engine/src/types/ability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2116,6 +2116,19 @@ pub enum RestrictionPlayerScope {
/// Resolved to `SpecificPlayer` by `add_restriction` after parent target
/// propagation, without declaring a second target slot.
ParentTargetedPlayer,
/// CR 109.4 + CR 608.2c + CR 608.2h: The controller of the parent effect's
/// object target — "its controller" where "it" is the object countered/
/// destroyed/tapped by the parent instruction (Render Silent: "Counter target
/// spell. Its controller can't cast spells this turn."). Resolved to
/// `SpecificPlayer` by `add_restriction::fill_runtime_fields` at restriction-
/// creation time via `ability_utils::parent_target_controller`, capturing the
/// referent's controller from last-known information (the object has left the
/// stack by the time the chained restriction resolves). Distinct from
/// `ParentTargetedPlayer` ("that player", CR 608.2c — a reused *player* target):
/// this derives a player from an *object* target's controller (CR 109.4).
/// Mirrors `PlayerFilter::ParentObjectTargetController` /
/// `PlayerScope::ParentObjectTargetController` on the sibling enums.
ParentObjectTargetController,
OpponentsOfSourceController,
/// CR 508.5 / CR 508.5a: The defending player for the source's attack
/// ("Whenever ~ attacks, defending player can't cast spells this turn." —
Expand Down
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ mod proliferate_zero_counter;
mod quirion_ranger_activation;
mod rage_reflection_double_strike_grant;
mod refurbished_familiar;
mod render_silent_cant_cast;
mod riot_control_regression;
mod ripples_of_undeath_regression;
mod rite_of_consumption_damage;
Expand Down
Loading
Loading