From e38499bd31cff927061a7471a2b0f9da7d670c61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:12:37 +0000 Subject: [PATCH 1/3] Add Render Silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counter target spell. Its controller can't cast spells this turn. The second sentence was previously swallowed (coverage gap_count 1). Add the general primitive "affected player = controller of the parent effect's object target" to the temporary-restriction subsystem, composing the existing `parent_target_controller` anaphora resolver (stack -> last-known-information, CR 608.2h) with the established " can't cast spells this turn" restriction-effect path (Silence / Xantid Swarm). - types: nullary `RestrictionPlayerScope::ParentObjectTargetController` (CR 109.4 / 608.2c / 608.2h). Distinct from `ParentTargetedPlayer` (a reused player target) — this derives a player from an object target's controller. - parser: one `value(.., tag("its controller"))` arm in `try_parse_cant_cast_spells_effect` (nom combinator; Gate A clean). - resolver: `add_restriction::fill_runtime_fields` lowers the scope to `SpecificPlayer` at restriction-creation time (capture-at-resolution, after the countered spell has left the stack); fail-closed if no object referent. - exhaustive-match arms at casting / combat / derived_views (no wildcard). Generalizes to the class "Counter/Destroy/Tap target X. Its controller can't cast spells this turn". Verified: coverage supported:true gap_count:0, semantic-audit 0 findings, discriminating cast-pipeline test (countered spell's controller barred, caster free, non-vacuous reach-guard). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WQcP6woAkU5iEBTkA9oQ6J --- crates/engine/src/game/casting.rs | 11 ++ crates/engine/src/game/combat.rs | 6 +- crates/engine/src/game/derived_views.rs | 3 + .../src/game/effects/add_restriction.rs | 110 +++++++++++++ crates/engine/src/parser/oracle_effect/mod.rs | 8 + .../engine/src/parser/oracle_effect/tests.rs | 62 ++++++++ crates/engine/src/types/ability.rs | 13 ++ crates/engine/tests/integration/main.rs | 1 + .../integration/render_silent_cant_cast.rs | 149 ++++++++++++++++++ 9 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/integration/render_silent_cant_cast.rs diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 67f4ba2144..2c9002af5a 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -509,6 +509,17 @@ fn restriction_scope_matches_player( debug_assert!(false, "ScopedPlayer should be resolved by add_restriction"); false } + RestrictionPlayerScope::ParentObjectTargetController => { + // CR 109.4: resolved to `SpecificPlayer` by `add_restriction` when the + // restriction is created (via `parent_target_controller`), so an + // unresolved scope here means the object referent could not be found — + // fail-closed, restrict no one. + debug_assert!( + false, + "ParentObjectTargetController should be resolved by add_restriction" + ); + false + } RestrictionPlayerScope::OpponentsOfSourceController => { source_controller.is_some_and(|controller| controller != caster) } diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index 98b82473d0..3fdc65a351 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -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 { diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index 388494c819..d628473996 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -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 diff --git a/crates/engine/src/game/effects/add_restriction.rs b/crates/engine/src/game/effects/add_restriction.rs index 1e24c93f29..5bc2abfd5d 100644 --- a/crates/engine/src/game/effects/add_restriction.rs +++ b/crates/engine/src/game/effects/add_restriction.rs @@ -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 => {} @@ -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 diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 74d34500ea..cf6826b2e7 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -2805,6 +2805,14 @@ fn try_parse_cant_cast_spells_effect(tp: TextPair<'_>) -> Option ObjectId { + let spell = engine::game::zones::create_object( + runner.state_mut(), + CardId(701), + controller, + "Shock".to_string(), + Zone::Stack, + ); + if let Some(obj) = runner.state_mut().objects.get_mut(&spell) { + obj.card_types.core_types = vec![CoreType::Instant]; + } + runner.state_mut().stack.push_back(StackEntry { + id: spell, + source_id: spell, + controller, + kind: StackEntryKind::Spell { + card_id: CardId(701), + ability: None, + casting_variant: CastingVariant::Normal, + actual_mana_spent: 0, + }, + }); + spell +} + +/// CR 109.4 + CR 701.6a: Render Silent counters the targeted spell (into its +/// controller's graveyard) AND restricts that spell's controller from casting +/// spells for the rest of the turn. The caster is unaffected. +/// +/// Reverting the parser scope arm (Step 2) makes the "its controller" clause +/// swallow to Unimplemented, so no restriction is created and assertion (iv) +/// fails. Binding the restriction to `self.controller` (the caster) instead of +/// the countered spell's controller makes (iv) OR (v) fail. +#[test] +fn render_silent_restricts_countered_spells_controller_not_the_caster() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // P0 (caster): Render Silent + a backup instant to prove the caster is NOT + // restricted. Ample untapped mana so the backup's castability turns on the + // restriction, not on mana exhaustion. + let mut rs = scenario.add_spell_to_hand_from_oracle(P0, "Render Silent", true, RENDER_SILENT); + rs.with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Blue, ManaCostShard::Black], + }); + let render_silent = rs.id(); + + let mut p0_backup = + scenario.add_spell_to_hand_from_oracle(P0, "P0 Backup", true, "Draw a card."); + p0_backup.with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Blue], + }); + let p0_backup = p0_backup.id(); + + scenario.add_basic_land(P0, ManaColor::Blue); + scenario.add_basic_land(P0, ManaColor::Blue); + scenario.add_basic_land(P0, ManaColor::Blue); + scenario.add_basic_land(P0, ManaColor::Black); + + // P1 (opponent): a follow-up instant + mana to prove the restriction blocks + // this player specifically after the counter. + let mut p1_followup = + scenario.add_spell_to_hand_from_oracle(P1, "P1 Followup", true, "Draw a card."); + p1_followup.with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Blue], + }); + let p1_followup = p1_followup.id(); + scenario.add_basic_land(P1, ManaColor::Blue); + + let mut runner = scenario.build(); + + // The spell being countered — controlled and owned by P1. + let bait = put_spell_on_stack(&mut runner, P1); + + // (i) Positive reach-guard: BEFORE the restriction exists, P1 can cast their + // follow-up spell. Proves the negative assertion (iv) is not vacuous. + assert!( + can_cast_object_now(runner.state(), P1, p1_followup), + "reach-guard: P1's follow-up must be castable before Render Silent resolves" + ); + + // (ii) P0 casts Render Silent targeting P1's spell on the stack. + runner.cast(render_silent).target_objects(&[bait]).resolve(); + + // (iii) A counter happened: the bait left the stack into P1's graveyard. + assert!( + runner.state().stack.is_empty(), + "the bait spell must be countered (off the stack)" + ); + assert_eq!( + runner.state().objects[&bait].zone, + Zone::Graveyard, + "the countered spell goes to its owner's graveyard (CR 701.6a)" + ); + assert!( + runner.state().players[P1.0 as usize] + .graveyard + .contains(&bait), + "the countered spell must be in P1's graveyard" + ); + + // (iv) REVERT-FAILING: P1 (the countered spell's controller) can no longer + // cast spells this turn. Drives casting.rs::restriction_scope_matches_player, + // not merely the parsed AST. + assert!( + !can_cast_object_now(runner.state(), P1, p1_followup), + "P1 (countered spell's controller) must be barred from casting this turn" + ); + + // (v) MULTI-AUTHORITY: P0 (the caster of Render Silent) is NOT restricted — + // the restriction bound to the countered spell's controller (P1), not to the + // caster (`self.controller` fallback). Fails if the wrong authority was used. + assert!( + can_cast_object_now(runner.state(), P0, p0_backup), + "P0 (the caster) must still be able to cast — the restriction targets P1, not P0" + ); +} From 1ac964df2b20ba5c90b988d788523c3521c6060e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 08:21:33 +0000 Subject: [PATCH 2/3] test(engine): cover Render Silent owner != controller binding boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address maintainer review: add a discriminating integration test for the owner-vs-controller branch of the "its controller can't cast spells this turn" restriction. The countered spell is OWNED by P0 (also the Render Silent caster) but CONTROLLED by P1, so the restriction must bind to the controller (P1) — a collapse to the owner (CR 701.6a graveyard owner) or to the caster's self.controller (both P0) is ruled out by asserting P0 stays castable while P1 is barred. Exercises the path previously called out as untested: after the counter the spell has left the stack with no LKI snapshot (LKI is captured only on battlefield/exile exit), so parent_target_controller reads the graveyard object's retained controller, which stays P1 because a stack->graveyard move does not run reset_for_battlefield_exit. A later stack-exit reset or lookup change that collapsed this to the owner would fail the new test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WQcP6woAkU5iEBTkA9oQ6J --- .../integration/render_silent_cant_cast.rs | 148 +++++++++++++++++- 1 file changed, 143 insertions(+), 5 deletions(-) diff --git a/crates/engine/tests/integration/render_silent_cant_cast.rs b/crates/engine/tests/integration/render_silent_cant_cast.rs index b19c60efae..85a83f1836 100644 --- a/crates/engine/tests/integration/render_silent_cant_cast.rs +++ b/crates/engine/tests/integration/render_silent_cant_cast.rs @@ -7,11 +7,12 @@ //! target of the parent Counter), captured from last-known information as the //! restriction is created — NOT the caster of Render Silent. //! -//! NOTE (CR 608.2h): this fixture has the caster (P0) counter a spell OWNED and -//! controlled by the opponent (P1) — owner == controller for the countered -//! spell. The owner != controller last-known-information branch (an -//! opponent-OWNED spell whose controller differs) is intentionally not exercised -//! here. +//! The first fixture has the caster (P0) counter a spell OWNED and controlled by +//! the opponent (P1) — owner == controller for the countered spell. The second +//! fixture (`render_silent_binds_to_controller_not_owner_when_they_differ`) +//! covers the owner != controller boundary: a spell OWNED by P0 (the caster) but +//! CONTROLLED by P1, so the restriction must bind to the controller (P1) and not +//! collapse to the owner or the caster (both P0). use engine::game::casting::can_cast_object_now; use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; @@ -52,6 +53,143 @@ fn put_spell_on_stack(runner: &mut GameRunner, controller: PlayerId) -> ObjectId spell } +/// Put a spell on the stack whose OWNER and CONTROLLER differ: `owner` owns the +/// card (so a counter sends it to `owner`'s graveyard, CR 701.6a) while +/// `controller` controls it on the stack (e.g. a spell cast from another +/// player's zone). Mirrors `put_spell_on_stack` but decouples the two so the +/// "its controller" anaphor can be distinguished from the card's owner. +fn put_spell_on_stack_owner_controller( + runner: &mut GameRunner, + owner: PlayerId, + controller: PlayerId, +) -> ObjectId { + let spell = engine::game::zones::create_object( + runner.state_mut(), + CardId(702), + owner, + "Shock".to_string(), + Zone::Stack, + ); + if let Some(obj) = runner.state_mut().objects.get_mut(&spell) { + obj.card_types.core_types = vec![CoreType::Instant]; + // CR 109.4: the spell is controlled by `controller`, distinct from its + // owner. create_object initialized controller == owner; override it. + obj.controller = controller; + } + runner.state_mut().stack.push_back(StackEntry { + id: spell, + source_id: spell, + controller, + kind: StackEntryKind::Spell { + card_id: CardId(702), + ability: None, + casting_variant: CastingVariant::Normal, + actual_mana_spent: 0, + }, + }); + spell +} + +/// CR 109.4 + CR 608.2c + CR 608.2h: the owner != controller boundary the sibling +/// fixture leaves untested. The countered spell is OWNED by P0 (also the Render +/// Silent caster) but CONTROLLED by P1. "Its controller can't cast spells this +/// turn" must bind to the CONTROLLER (P1) — not the owner and not the caster, +/// which are BOTH P0. Because those two failure modes collapse onto the same +/// player, a single assertion (P0 stays castable) rules out both at once: +/// +/// - Binding to the owner (CR 701.6a graveyard owner = P0) → P0 restricted → fail. +/// - Binding to `self.controller` (the caster fallback = P0) → P0 restricted → fail. +/// - Binding to the countered spell's controller (P1) → only P1 restricted → pass. +/// +/// This exercises the exact path called out as unverified: after the counter the +/// spell has left the stack with NO LKI snapshot (LKI is captured only on +/// battlefield/exile exit, not stack exit), so `parent_target_controller` reads +/// the graveyard object's RETAINED controller — which must still be P1 because a +/// stack→graveyard move does not run `reset_for_battlefield_exit`. If a later +/// stack-exit reset or lookup change collapsed that to the owner, this fails. +#[test] +fn render_silent_binds_to_controller_not_owner_when_they_differ() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // P0: Render Silent + a backup instant. P0 is the caster AND the owner of the + // countered spell, so its castability after resolution is the discriminator. + let mut rs = scenario.add_spell_to_hand_from_oracle(P0, "Render Silent", true, RENDER_SILENT); + rs.with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Blue, ManaCostShard::Black], + }); + let render_silent = rs.id(); + + let mut p0_backup = + scenario.add_spell_to_hand_from_oracle(P0, "P0 Backup", true, "Draw a card."); + p0_backup.with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Blue], + }); + let p0_backup = p0_backup.id(); + + scenario.add_basic_land(P0, ManaColor::Blue); + scenario.add_basic_land(P0, ManaColor::Blue); + scenario.add_basic_land(P0, ManaColor::Blue); + scenario.add_basic_land(P0, ManaColor::Black); + + // P1: the controller of the countered spell — the player that must be barred. + let mut p1_followup = + scenario.add_spell_to_hand_from_oracle(P1, "P1 Followup", true, "Draw a card."); + p1_followup.with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Blue], + }); + let p1_followup = p1_followup.id(); + scenario.add_basic_land(P1, ManaColor::Blue); + + let mut runner = scenario.build(); + + // The spell being countered — OWNED by P0, CONTROLLED by P1. + let bait = put_spell_on_stack_owner_controller(&mut runner, P0, P1); + + // (i) Positive reach-guard: BEFORE the restriction exists, P1 can cast. + assert!( + can_cast_object_now(runner.state(), P1, p1_followup), + "reach-guard: P1's follow-up must be castable before Render Silent resolves" + ); + + // (ii) P0 casts Render Silent targeting the spell on the stack. + runner.cast(render_silent).target_objects(&[bait]).resolve(); + + // (iii) The counter sent the spell to its OWNER's (P0's) graveyard (CR 701.6a). + assert!( + runner.state().stack.is_empty(), + "the bait spell must be countered (off the stack)" + ); + assert_eq!( + runner.state().objects[&bait].zone, + Zone::Graveyard, + "the countered spell goes to its owner's graveyard (CR 701.6a)" + ); + assert!( + runner.state().players[P0.0 as usize] + .graveyard + .contains(&bait), + "the countered spell must be in P0's (the owner's) graveyard" + ); + + // (iv) REVERT-FAILING: P1 (the countered spell's CONTROLLER) is barred. + assert!( + !can_cast_object_now(runner.state(), P1, p1_followup), + "P1 (countered spell's controller) must be barred from casting this turn" + ); + + // (v) OWNER/CASTER NOT RESTRICTED: P0 owns the countered spell AND cast Render + // Silent. Binding that collapsed to the owner or to the caster (both P0) would + // restrict P0 here — this asserts the restriction bound to the controller (P1). + assert!( + can_cast_object_now(runner.state(), P0, p0_backup), + "P0 (owner of the countered spell AND the caster) must still be able to cast" + ); +} + /// CR 109.4 + CR 701.6a: Render Silent counters the targeted spell (into its /// controller's graveyard) AND restricts that spell's controller from casting /// spells for the rest of the turn. The caster is unaffected. From c4af6e9bb248e9a440de3fed1ad650c79cb66359 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 10:33:49 +0000 Subject: [PATCH 3/3] fix(engine): make unresolved ParentObjectTargetController restriction fail-closed, not panic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address maintainer review: the enforcement arm in casting::restriction_scope_matches_player did debug_assert!(false) for RestrictionPlayerScope::ParentObjectTargetController, but add_restriction's fill_runtime_fields can legitimately leave that scope unresolved when there is no object referent (proven by parent_object_target_controller_unresolved_without_object_target). That made the documented fail-closed path unsafe: a castability query against that stored state would panic in debug/test builds instead of restricting no one. Remove the debug assertion for this arm so it genuinely returns false (fail-closed). Unlike the always-resolved sibling scopes (TargetedPlayer, ScopedPlayer), this scope can reachably remain unresolved, so an unresolved value is a valid state, not a bug. Add an integration test that drives the public can_cast_object_now against the stored unresolved restriction and asserts it restricts no one without panicking — proving the fail-closed behavior, not just the stored enum shape. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WQcP6woAkU5iEBTkA9oQ6J --- crates/engine/src/game/casting.rs | 18 +++--- .../integration/render_silent_cant_cast.rs | 56 +++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 2c9002af5a..fba23064cf 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -510,14 +510,16 @@ fn restriction_scope_matches_player( false } RestrictionPlayerScope::ParentObjectTargetController => { - // CR 109.4: resolved to `SpecificPlayer` by `add_restriction` when the - // restriction is created (via `parent_target_controller`), so an - // unresolved scope here means the object referent could not be found — - // fail-closed, restrict no one. - debug_assert!( - false, - "ParentObjectTargetController should be resolved by add_restriction" - ); + // 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 => { diff --git a/crates/engine/tests/integration/render_silent_cant_cast.rs b/crates/engine/tests/integration/render_silent_cant_cast.rs index 85a83f1836..0da392bf11 100644 --- a/crates/engine/tests/integration/render_silent_cant_cast.rs +++ b/crates/engine/tests/integration/render_silent_cant_cast.rs @@ -16,6 +16,9 @@ use engine::game::casting::can_cast_object_now; use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::{ + GameRestriction, ProhibitedActivity, RestrictionExpiry, RestrictionPlayerScope, +}; use engine::types::card_type::CoreType; use engine::types::game_state::{CastingVariant, StackEntry, StackEntryKind}; use engine::types::identifiers::{CardId, ObjectId}; @@ -53,6 +56,59 @@ fn put_spell_on_stack(runner: &mut GameRunner, controller: PlayerId) -> ObjectId spell } +/// CR 109.4 fail-closed enforcement: if an "its controller can't cast spells" +/// restriction is ever stored with an UNRESOLVED `ParentObjectTargetController` +/// scope (no object referent — the malformed/hostile state that +/// `add_restriction`'s `parent_object_target_controller_unresolved_without_object_target` +/// proves `fill_runtime_fields` can leave), a later castability query must return +/// "unrestricted" and MUST NOT panic. This drives the public `can_cast_object_now` +/// against exactly that stored state — it panicked before the enforcement arm's +/// `debug_assert!(false)` was removed, and now returns fail-closed (restrict no +/// one). Guards `casting::restriction_scope_matches_player`. +#[test] +fn unresolved_parent_object_target_controller_restriction_is_fail_closed() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let mut p0_spell = scenario.add_spell_to_hand_from_oracle(P0, "P0 Spell", true, "Draw a card."); + p0_spell.with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Blue], + }); + let p0_spell = p0_spell.id(); + scenario.add_basic_land(P0, ManaColor::Blue); + + let mut runner = scenario.build(); + + // Baseline: castable before any restriction exists. + assert!( + can_cast_object_now(runner.state(), P0, p0_spell), + "sanity: P0's spell must be castable before the restriction is stored" + ); + + // Store the hostile UNRESOLVED restriction directly — the exact state the + // sibling unit test proves `fill_runtime_fields` leaves when there is no + // object referent (scope stays `ParentObjectTargetController`, never lowered + // to `SpecificPlayer`). + runner + .state_mut() + .restrictions + .push(GameRestriction::ProhibitActivity { + source: ObjectId(9999), + affected_players: RestrictionPlayerScope::ParentObjectTargetController, + expiry: RestrictionExpiry::EndOfTurn, + activity: ProhibitedActivity::CastSpells { spell_filter: None }, + }); + + // Fail-closed: the unresolved scope restricts NO ONE and must not panic. This + // call routes through `restriction_scope_matches_player`'s + // `ParentObjectTargetController` arm. + assert!( + can_cast_object_now(runner.state(), P0, p0_spell), + "an unresolved ParentObjectTargetController restriction must restrict no one (fail-closed)" + ); +} + /// Put a spell on the stack whose OWNER and CONTROLLER differ: `owner` owns the /// card (so a counter sends it to `owner`'s graveyard, CR 701.6a) while /// `controller` controls it on the stack (e.g. a spell cast from another