Skip to content

Add Render Silent#5110

Open
keloide wants to merge 5 commits into
phase-rs:mainfrom
keloide:claude/phase-developer-track-0stiny
Open

Add Render Silent#5110
keloide wants to merge 5 commits into
phase-rs:mainfrom
keloide:claude/phase-developer-track-0stiny

Conversation

@keloide

@keloide keloide commented Jul 4, 2026

Copy link
Copy Markdown

Summary

Adds engine support for Render Silent — "Counter target spell. Its controller can't cast spells this turn."

The first sentence already lowered to Effect::Counter; the second was silently swallowed (gap_count: 1). This adds 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 "<player> can't cast spells this turn" restriction-effect path (Silence / Xantid Swarm). Built for the class — "Counter/Destroy/Tap target X. Its controller can't cast spells this turn" — not the single card.

Files changed

  • crates/engine/src/types/ability.rs
  • crates/engine/src/parser/oracle_effect/mod.rs
  • crates/engine/src/game/effects/add_restriction.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/combat.rs
  • crates/engine/src/game/derived_views.rs
  • crates/engine/src/parser/oracle_effect/tests.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/render_silent_cant_cast.rs (new)

Key design

  • types: nullary RestrictionPlayerScope::ParentObjectTargetController. Distinct from ParentTargetedPlayer (a reused player target, which routes through target_player()'s unwrap_or(self.controller) fallback and would wrongly restrict the caster) — this derives a player from an object target's controller. Mirrors the engine-native PlayerFilter::ParentObjectTargetController / PlayerScope::ParentObjectTargetController on the sibling enums. Additive nullary variant on a #[serde(tag,content)] enum — no wire migration.
  • parser: one value(RestrictionPlayerScope::ParentObjectTargetController, tag("its controller")) arm in try_parse_cant_cast_spells_effect (nom combinator, no string dispatch).
  • 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 — resolved via last-known information); fail-closed if there is no object referent.
  • enforcement: exhaustive-match arms added at casting.rs / combat.rs / derived_views.rs — no wildcard fallback (the variant is always lowered to SpecificPlayer before storage).

CR references

  • CR 109.4 — controller of an object ("its controller")
  • CR 608.2c — later text modifies earlier text; anaphor resolution order
  • CR 608.2h — value determined once / last-known information (countered spell has left the stack)
  • CR 611.2a — continuous effect from a resolved spell lasts "until end of turn"
  • CR 701.6 / 701.6a — Counter → owner's graveyard
  • CR 101.2 / 601.2a / 602.5 — casting-prohibition domain

Track

Developer

LLM

Model: Claude Opus 4.8
Thinking: high

Tier: Frontier

Gate A

./scripts/check-parser-combinators.shexit 0 (no violations; the script prints no output on success). The only added parser line is a value(_, tag("its controller")) combinator arm — no .contains / .split_once / .starts_with / match-arm string literals introduced.

Anchored on

  • crates/engine/src/parser/oracle_effect/mod.rs:2804value(RestrictionPlayerScope::ParentTargetedPlayer, tag("that player")): sibling anaphora scope arm in the same alt(), same value(_, tag(...)) combinator family that my tag("its controller") arm mirrors.
  • crates/engine/src/parser/oracle_effect/mod.rs:2819value(RestrictionPlayerScope::DefendingPlayer, tag("defending player")): sibling non-target-derived scope arm in the same alt() (Xantid Swarm), same combinator shape.
  • crates/engine/src/game/effects/add_restriction.rs:120RestrictionPlayerScope::DefendingPlayer => { … SpecificPlayer(p) }: the capture-at-resolution lowering my new ParentObjectTargetController arm follows exactly.
  • crates/engine/src/game/effects/add_restriction.rs:138RestrictionPlayerScope::ScopedPlayer => { … SpecificPlayer(p) }: second analog of the same placeholder-scope → SpecificPlayer resolution pattern.

Verification

Developer track — all Step 6 commands ran clean:

  • cargo fmt --all — clean
  • ./scripts/check-parser-combinators.sh — clean (exit 0)
  • cargo clippy-strict — clean (exit 0)
  • cargo test -p engine --lib -- render_silent parent_object_target_controller — 3 pass / 0 fail
  • cargo test -p engine --test integration -- render_silent — 2 pass / 0 fail (drives the real cast pipeline: countered spell's controller barred, caster still free; positive reach-guard makes the negative non-vacuous)
  • ./scripts/gen-card-data.sh — Render Silent: 0 Unimplemented entries (both sentences parsed)
  • cargo coverage — Render Silent: supported: true, gap_count: 0
  • cargo semantic-audit — Render Silent: 0 findings

Scope Expansion

None.

Validation Failures

None.

CI Failures

None.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WQcP6woAkU5iEBTkA9oQ6J


Generated by Claude Code

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 "<player> 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WQcP6woAkU5iEBTkA9oQ6J
@keloide keloide requested a review from matthewevans as a code owner July 4, 2026 23:44

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the ParentObjectTargetController restriction player scope to support cards like Render Silent, resolving the restriction to the controller of the parent effect's object target using last-known information. Feedback on the changes suggests removing a debug_assert! in restriction_scope_matches_player because it will trigger unexpected panics in debug builds and tests when the scope is legitimately left unresolved as a valid fail-closed state.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +512 to +522
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The debug_assert!(false, ...) here will trigger a panic in debug builds and tests whenever a ParentObjectTargetController restriction remains unresolved (e.g., when there is no parent object target, which is a valid fail-closed state tested in parent_object_target_controller_unresolved_without_object_target). Since this is a valid fallback state that should gracefully restrict no one, the debug_assert! should be removed to prevent unexpected panics during gameplay or testing.

        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.
            false
        }

@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Jul 4, 2026
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 15 card(s), 23 signature(s) (baseline: main 623d82ab3d22)

5 card(s) · ability/attacking · removed: attacking

Examples: Curse of Disturbance, Curse of Opulence, Curse of Verbosity (+2 more)

2 card(s) · ability/BecomeBlocked · added: BecomeBlocked (target=unblocked attacking creature)

Examples: Curtain of Light, Dazzling Beauty

2 card(s) · ability/become · removed: become

Examples: Curtain of Light, Dazzling Beauty

1 card(s) · ability/AddRestriction · added: AddRestriction (duration=until end of turn)

Examples: Render Silent

1 card(s) · ability/BecomeBlocked · added: BecomeBlocked (kind=activated, target=unblocked attacking creature)

Examples: Trap Runner

1 card(s) · ability/BecomeBlocked · added: BecomeBlocked (target=attacking creature)

Examples: Fog Patch

1 card(s) · ability/BecomeBlocked · added: BecomeBlocked (target=attacking creature, targets=X-X)

Examples: Choking Vines

1 card(s) · ability/Draw · added: Draw

Examples: Curse of Verbosity

1 card(s) · ability/Draw · field conditional: P1P1 counters on target ≥ 1

Examples: Oblivion's Hunger

1 card(s) · ability/FlipCoin · added: FlipCoin (kind=activated, lose=yes, win=yes)

Examples: Wirefly Hive

1 card(s) · ability/FlipCoin · removed: FlipCoin (kind=activated, lose=yes, win=yes)

Examples: Wirefly Hive

1 card(s) · ability/GainLife · added: GainLife (amount=2)

Examples: Curse of Vitality

1 card(s) · trigger/Phase · added: Phase (active in=battlefield, condition=# of named "keeper of ~" in battlefield you control creature = 0, constraint=only during your turn, phase=upkeep)

Examples: Kookus

1 card(s) · trigger/Phase · removed: Phase (active in=battlefield, condition=# of named "keeper of ~, ~ deals 3 damage to you and attacks this turn if able" in battlefield you control creature = 0…

Examples: Kookus

1 card(s) · ability/SearchLibrary · added: SearchLibrary (find=named "spirit of the night", kind=activated)

Examples: Urborg Panther

1 card(s) · ability/SearchLibrary · removed: SearchLibrary (find=named "spirit of the night", kind=activated)

Examples: Urborg Panther

1 card(s) · ability/Token · added: Token (token=+2/+2 Black Zombie (Creature Zombie))

Examples: Curse of Disturbance

1 card(s) · ability/Token · added: Token (token=Gold (Artifact Gold))

Examples: Curse of Opulence

1 card(s) · ability/become · removed: become (kind=activated)

Examples: Trap Runner

1 card(s) · ability/become · removed: become (targets=X-X)

Examples: Choking Vines

1 card(s) · ability/can't · removed: can't (duration=until end of turn)

Examples: Render Silent

1 card(s) · ability/unknown · added: unknown

Examples: Galactus, Devourer of Worlds

1 card(s) · ability/unknown · removed: unknown

Examples: Galactus, Devourer of Worlds

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@matthewevans matthewevans self-assigned this Jul 5, 2026
@matthewevans matthewevans removed their assignment Jul 5, 2026
@matthewevans matthewevans added the enhancement New feature or request label Jul 5, 2026
@matthewevans

Copy link
Copy Markdown
Member

Holding maintainer review for now because this parser/engine PR still has a Baseline pending parse-detail diff. I want the card-level parse diff for the current head before approving or requesting changes, since that diff is required evidence for parser-surface PRs.

@matthewevans matthewevans self-assigned this Jul 5, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MED] Please add a discriminating test for the owner-vs-controller branch before this lands. Evidence: crates/engine/tests/integration/render_silent_cant_cast.rs explicitly says the owner != controller last-known-information branch is intentionally not exercised, and crates/engine/src/game/effects/add_restriction.rs relies on parent_target_controller to bind RestrictionPlayerScope::ParentObjectTargetController at restriction creation. That is the important correctness boundary for this new scope: Render Silent must restrict the controller/caster of the countered spell, not the card owner and not the Render Silent caster. The current integration fixture covers only owner==controller, so it would not fail if a later stack-exit/reset path or lookup change made this scope collapse to owner in the exact case the implementation comments claim to handle.

A good proof would set up a stack spell whose owner differs from its controller, resolve Render Silent against it, and assert the spell controller is barred from casting while the owner remains able to cast. This can be an integration test or a focused add_restriction/parent_target_controller unit test, but it needs to cover the branch currently called out as untested.

@matthewevans matthewevans removed their assignment Jul 5, 2026
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WQcP6woAkU5iEBTkA9oQ6J

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The owner-vs-controller integration test addresses the previous review, but there is still one current-head blocker before this is ready.

The PR explicitly documents and tests the unresolved ParentObjectTargetController state as fail-closed in add_restriction: parent_object_target_controller_unresolved_without_object_target leaves affected_players: ParentObjectTargetController stored when there is no object target. The enforcement arm in casting::restriction_scope_matches_player also says that unresolved state should "fail-closed, restrict no one", but it still executes debug_assert!(false, "ParentObjectTargetController should be resolved by add_restriction") before returning false.

That means the fail-closed path is not actually safe in debug/test builds: any later castability query while that hostile fixture state exists will panic instead of returning unrestricted. Please remove the debug assertion for this specific fail-closed arm, or change the model so unresolved ParentObjectTargetController cannot be stored. Add a small assertion that drives can_cast_object_now against the unresolved fixture so the fail-closed behavior is proven, not just the stored enum shape.

… fail-closed, not panic

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WQcP6woAkU5iEBTkA9oQ6J

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] The current parse-diff has unrelated card-level blast radius for a Render Silent PR. Evidence: the current <!-- coverage-parse-diff --> sticky reports 15 cards / 23 signatures, including Curse of Disturbance/Opulence/Verbosity losing attacking, Curtain of Light/Dazzling Beauty BecomeBlocked, Trap Runner, Fog Patch, Choking Vines, Kookus, Urborg Panther, Wirefly Hive, Galactus, and others, while the PR summary scopes the change to Render Silent / its controller can't cast spells this turn.

Why it matters: parser-surface approvals require confronting the card-level diff, and unexplained gains/losses outside the stated class are scope contamination even when CI is green. Suggested fix: reduce/rebase the branch so this PR's parse diff is only Render Silent, or explicitly split/explain the unrelated parser changes with tests and scope.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants