Add Render Silent#2
Conversation
Parse changes introduced by this PR · 178 card(s), 116 signature(s) (baseline: main
|
|
Addressed in fa70e1b — added the discriminating owner≠controller test you asked for. New test:
Because owner and caster are the same player (P0) here, that single "P0 stays castable" assertion rules out both failure modes you called out at once: a collapse to the CR 701.6a graveyard owner (P0) or to the On the boundary itself: this is exactly the path the old NOTE flagged. After the counter the spell leaves the stack with no LKI snapshot (LKI is captured only on battlefield/exile exit — Verified locally: Generated by Claude Code |
|
Fixed in fc52094. You're right — the arm was self-contradictory: it documented "fail-closed, restrict no one" but ran Fix ( Proof test ( Verified locally:
Generated by Claude Code |
fc52094 to
82d5ebf
Compare
|
Rebased — the branch now contains only the 3 Render Silent commits on top of the PR base ( Root cause of the blast radius: the branch had picked up two Expected parse-diff after this push: 1 card (Render Silent) — the Verified the rebased branch against the base (
CI is re-running on 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
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
… 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
82d5ebf to
c4af6e9
Compare
|
Resolved both the parse-diff scope and the coverage-regression CI in one move — the two were mutually exclusive because the PR base was stale. Root cause: the base
Fix: fast-forwarded
The 3 commits are unchanged and verified green on this base ( Generated by Claude Code |
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 existingparent_target_controlleranaphora 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.rscrates/engine/src/parser/oracle_effect/mod.rscrates/engine/src/game/effects/add_restriction.rscrates/engine/src/game/casting.rscrates/engine/src/game/combat.rscrates/engine/src/game/derived_views.rscrates/engine/src/parser/oracle_effect/tests.rscrates/engine/tests/integration/main.rscrates/engine/tests/integration/render_silent_cant_cast.rs(new)Key design
RestrictionPlayerScope::ParentObjectTargetController. Distinct fromParentTargetedPlayer(a reused player target, which routes throughtarget_player()'sunwrap_or(self.controller)fallback and would wrongly restrict the caster) — this derives a player from an object target's controller. Mirrors the engine-nativePlayerFilter::ParentObjectTargetController/PlayerScope::ParentObjectTargetControlleron the sibling enums. Additive nullary variant on a#[serde(tag,content)]enum — no wire migration.value(RestrictionPlayerScope::ParentObjectTargetController, tag("its controller"))arm intry_parse_cant_cast_spells_effect(nom combinator, no string dispatch).add_restriction::fill_runtime_fieldslowers the scope toSpecificPlayerat 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.casting.rs/combat.rs/derived_views.rs— no wildcard fallback (the variant is always lowered toSpecificPlayerbefore storage).CR references
CR 109.4— controller of an object ("its controller")CR 608.2c— later text modifies earlier text; anaphor resolution orderCR 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 graveyardCR 101.2 / 601.2a / 602.5— casting-prohibition domainTrack
Developer
LLM
Model: Claude Opus 4.8
Thinking: high
Tier: Frontier
Gate A
./scripts/check-parser-combinators.sh→ exit 0 (no violations; the script prints no output on success). The only added parser line is avalue(_, 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:2804—value(RestrictionPlayerScope::ParentTargetedPlayer, tag("that player")): sibling anaphora scope arm in the samealt(), samevalue(_, tag(...))combinator family that mytag("its controller")arm mirrors.crates/engine/src/parser/oracle_effect/mod.rs:2819—value(RestrictionPlayerScope::DefendingPlayer, tag("defending player")): sibling non-target-derived scope arm in the samealt()(Xantid Swarm), same combinator shape.crates/engine/src/game/effects/add_restriction.rs:120—RestrictionPlayerScope::DefendingPlayer => { … SpecificPlayer(p) }: the capture-at-resolution lowering my newParentObjectTargetControllerarm follows exactly.crates/engine/src/game/effects/add_restriction.rs:138—RestrictionPlayerScope::ScopedPlayer => { … SpecificPlayer(p) }: second analog of the same placeholder-scope →SpecificPlayerresolution 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 failcargo 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: 0cargo semantic-audit— Render Silent: 0 findingsScope Expansion
None.
Validation Failures
None.
CI Failures
None.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WQcP6woAkU5iEBTkA9oQ6J
Generated by Claude Code