Skip to content

Add Render Silent#2

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

Add Render Silent#2
keloide wants to merge 3 commits into
mainfrom
claude/phase-developer-track-0stiny

Conversation

@keloide

@keloide keloide commented Jul 4, 2026

Copy link
Copy Markdown
Owner

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

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 178 card(s), 116 signature(s) (baseline: main 1badc1920dab)

31 card(s) · ability/ApplyPerpetual · added: ApplyPerpetual

Examples: Angel of Unity, Argivian Welcome, Bloodsprout Talisman (+28 more)

22 card(s) · ability/grant Haste · field affects: parent targetlast created

Examples: A-Saheeli, Filigree Master, Cadric, Soul Kindler, Chrome Dome (+19 more)

19 card(s) · ability/grant Haste · field target: last created

Examples: A-Saheeli, Filigree Master, Baral and Kari Zev, Chrome Dome (+16 more)

19 card(s) · ability/grant ability · removed: grant ability (affects=last created, grants=grant ability, target=last created)

Examples: Awakening Zone, Blisterpod, Brood Butcher (+16 more)

18 card(s) · ability/grant Haste · field duration: permanent

Examples: Cabaretti Confluence, Cadric, Soul Kindler, Chrome Dome (+15 more)

11 card(s) · ability/grant ability · removed: grant ability (affects=parent target, grants=grant ability)

Examples: Birthing Hulk, Brood Monitor, Call the Scions (+8 more)

10 card(s) · ability/grant ability · field duration: permanent

Examples: Abstruse Interference, Adverse Conditions, Blight Herder (+7 more)

8 card(s) · ability/grant trigger · field duration: permanent

Examples: Dance with Devils, Devils' Playground, Make Mischief (+5 more)

7 card(s) · ability/grant Haste · field target: triggering sourcelast created

Examples: Cadric, Soul Kindler, Flameshadow Conjuring, Helm of the Host (+4 more)

5 card(s) · ability/Pump · removed: Pump (p/t=+1/+1, target=any target)

Examples: Angel of Unity, Darigaaz's Whelp, Hex, Kellan's Companion (+2 more)

5 card(s) · ability/grant trigger · field target: last created

Examples: Dance with Devils, Devils' Playground, Mitotic Slime (+2 more)

4 card(s) · ability/DealDamage · field target: triggering sourceparent target or player

Examples: Blast of Genius, Erratic Explosion, Explosive Revelation (+1 more)

4 card(s) · ability/grant Haste · field affects: triggering sourcelast created

Examples: Baral and Kari Zev, Dalek Intensive Care, Krenko, Baron of Tin Street (+1 more)

4 card(s) · ability/grant ability · field target: last created

Examples: Blight Herder, Corpsehatch, Essence Feed (+1 more)

4 card(s) · ability/grant trigger · field affects: parent targetlast created

Examples: Dance with Devils, Devils' Playground, Mitotic Slime (+1 more)

3 card(s) · ability/Detain · field targets: 0-2

Examples: Archon of the Triumvirate, Azorius Justiciar, Lyev Decree

3 card(s) · ability/ReduceCost · removed: ReduceCost (affects=any target, duration=until end of turn, grants=ReduceCost, target=any target)

Examples: Bloodsprout Talisman, Chronicler of Worship, Geistchanneler

3 card(s) · ability/grant ability · field affects: parent targetlast created

Examples: Corpsehatch, Essence Feed, The Good Time Sleuth

3 card(s) · ability/grant ability · removed: grant ability (affects=triggering source, grants=grant ability)

Examples: Brood Keeper, Pawn of Ulamog, Sifter of Skulls

2 card(s) · trigger/ChangesZone · added: ChangesZone (active in=battlefield, to=battlefield, watches=self)

Examples: Infestation, Nazahn, Revered Bladesmith

2 card(s) · trigger/ChangesZone · removed: ChangesZone (active in=battlefield, to=battlefield, watches=self)

Examples: Infestation, Nazahn, Revered Bladesmith

2 card(s) · ability/MustAttack · field affects: parent targetlast created

Examples: Howlsquad Heavy, Legion Warboss

2 card(s) · ability/MustAttack · field target: triggering sourcelast created

Examples: Howlsquad Heavy, Legion Warboss

2 card(s) · ability/Pump · removed: Pump (p/t=+1/+0, target=any target)

Examples: Cantor of the Refrain, Scour the Scene

2 card(s) · ability/Pump · removed: Pump (p/t=+1/+0, target=you control creature)

Examples: Prairie Survivalist, Valiant Emberkin

… 91 more signature(s) (102 card-changes) — see parse-diff.json
  • 2 card(s) · ability/Pump · removed: Pump (p/t=+2/+2, target=any target)
  • 2 card(s) · ability/Pump · removed: Pump (p/t=-1/-1, target=any target)
  • 2 card(s) · ability/assign · removed: assign (duration=until end of turn, kind=activated)
  • 2 card(s) · ability/damage from toughness · added: damage from toughness (affects=parent target, duration=until end of turn, grants=damage from toughness, kind=activated, target=you control creature)
  • 2 card(s) · ability/grant Haste · field duration: until end of turnpermanent
  • 2 card(s) · ability/grant Haste · removed: grant Haste (affects=any target, duration=until end of turn, grants=grant Haste, target=any target)
  • 2 card(s) · ability/grant Haste · removed: grant Haste (affects=parent target, grants=grant Haste)
  • 2 card(s) · ability/grant Lifelink · removed: grant Lifelink (affects=any target, duration=until end of turn, grants=grant Lifelink, target=any target)
  • 2 card(s) · ability/grant Menace, grant Haste · field affects: parent targetlast created
  • 2 card(s) · ability/grant Menace, grant Haste · field target: last created
  • 2 card(s) · ability/grant trigger, grant Trample, grant Haste · field duration: permanent
  • 1 card(s) · ability/AddRestriction · added: AddRestriction (duration=until end of turn)
  • 1 card(s) · ability/ApplyPerpetual · added: ApplyPerpetual (conditional=additional cost was paid)
  • 1 card(s) · ability/ApplyPerpetual · added: ApplyPerpetual (conditional=previous effect outcome)
  • 1 card(s) · ability/ApplyPerpetual · added: ApplyPerpetual (conditional=revealed is creature)
  • 1 card(s) · ability/ApplyPerpetual · added: ApplyPerpetual (conditional=target is creature)
  • 1 card(s) · ability/ApplyPerpetual · added: ApplyPerpetual (kind=activated, timing=sorcery speed)
  • 1 card(s) · ability/BlockRestriction, grant Flying · removed: BlockRestriction, grant Flying (affects=last created, grants=BlockRestriction, grants=grant Flying, target=last created)
  • 1 card(s) · ability/Bounce · field target: named "~ from your graveyard" cardnamed "~" card
  • 1 card(s) · ability/Bounce · field targets: 0-1
  • 1 card(s) · ability/CastFromZone · removed: CastFromZone (target=any target)
  • 1 card(s) · ability/ChangeZone · field target: another named "~ from your graveyard" cardanother named "~" card
  • 1 card(s) · ability/ChangeZoneAll · field target: named "~ from their graveyard" cardnamed "~" card
  • 1 card(s) · ability/ChangeZoneAll · field target: named "~ from your graveyard" cardnamed "~" card
  • 1 card(s) · trigger/Clashed · added: Clashed (active in=battlefield, optional=yes, valid target=controller)
  • 1 card(s) · ability/Conjure · added: Conjure
  • 1 card(s) · ability/Conjure · removed: Conjure
  • 1 card(s) · ability/CreateDelayedTrigger · added: CreateDelayedTrigger (when=at next end step)
  • 1 card(s) · ability/CrewContribution(PowerDelta { delta: 2 },[Saddle, Crew]) · removed: CrewContribution(PowerDelta { delta: 2 },[Saddle, Crew]) (affects=last created, duration=until end of turn, grants=CrewContribution(PowerDelta { delta: 2 },[Sa…
  • 1 card(s) · trigger/DamageDone · added: DamageDone (active in=battlefield, valid source=another you)
  • 1 card(s) · ability/Discard · added: Discard (count=2, target=player)
  • 1 card(s) · ability/Mana · added: Mana (kind=activated, mana={B}{B}{B}{B})
  • 1 card(s) · ability/Mana · field mana: 2 of {W}{U}{B}{R}{G}one of: {W}{W}{U}{U}, {W}{W}{B}{B}, {W}{W}{R}{R}, {W}{W}{G}{G}, {U}{U}{B}{B}, {U}{U}{R}{R}, {U}{U}{G}{G}, {B}{B}{R}{R},…
  • 1 card(s) · ability/Mana · removed: Mana (kind=activated, mana={B}{B}{B}{B})
  • 1 card(s) · trigger/Phase · added: Phase (active in=battlefield, condition=cards in graveyard (you) ≥ 4, constraint=once per game, phase=postcombat main)
  • 1 card(s) · trigger/Phase · added: Phase (active in=battlefield, condition=life gained this turn (you) ≥ 4, constraint=once per game, phase=postcombat main)
  • 1 card(s) · trigger/Phase · removed: Phase (active in=battlefield, condition=cards in graveyard (you) ≥ 4, constraint=once per game, phase=postcombat main)
  • 1 card(s) · trigger/Phase · removed: Phase (active in=battlefield, condition=life gained this turn (you) ≥ 4, constraint=once per game, phase=postcombat main)
  • 1 card(s) · ability/Pump · removed: Pump (conditional=additional cost was paid, p/t=+2/+2, target=parent target)
  • 1 card(s) · ability/Pump · removed: Pump (conditional=previous effect outcome, p/t=+1/+1, target=parent target)
  • 1 card(s) · ability/Pump · removed: Pump (conditional=revealed is creature, p/t=+1/+1, target=any target)
  • 1 card(s) · ability/Pump · removed: Pump (conditional=target is creature, p/t=+3/+3, target=parent target)
  • 1 card(s) · ability/Pump · removed: Pump (kind=activated, p/t=-3/-3, target=opponent controls creature, timing=sorcery speed)
  • 1 card(s) · ability/Pump · removed: Pump (p/t=+0/-3, target=opponent controls creature)
  • 1 card(s) · ability/Pump · removed: Pump (p/t=+1/+1, target=parent target)
  • 1 card(s) · ability/Pump · removed: Pump (p/t=+2/+0, target=you control creature)
  • 1 card(s) · ability/Pump · removed: Pump (p/t=+3/+0, target=any target)
  • 1 card(s) · ability/Pump · removed: Pump (p/t=-1/-2, target=opponent controls creature)
  • 1 card(s) · ability/Pump · removed: Pump (p/t=-2/+0, target=creature)
  • 1 card(s) · ability/PutCounter · field timing: sorcery speed
  • 1 card(s) · ability/RaiseCost · removed: RaiseCost (affects=any target, duration=until end of turn, grants=RaiseCost, target=any target)
  • 1 card(s) · ability/RaiseCost · removed: RaiseCost (affects=any target, duration=until end of turn, grants=RaiseCost, target=parent target)
  • 1 card(s) · ability/SearchLibrary · added: SearchLibrary (find=named "arachnus web", kind=activated)
  • 1 card(s) · ability/SearchLibrary · added: SearchLibrary (find=named "dragonstorm globe or boulderborn dragon", kind=activated, reveal=yes)
  • 1 card(s) · ability/SearchLibrary · removed: SearchLibrary (find=named "~ globe or boulderborn dragon", kind=activated, reveal=yes)
  • 1 card(s) · ability/SearchLibrary · removed: SearchLibrary (find=named "~ web", kind=activated)
  • 1 card(s) · trigger/SpellCast · added: SpellCast (active in=battlefield, constraint=once per game, valid target=you)
  • 1 card(s) · trigger/SpellCast · removed: SpellCast (active in=battlefield, constraint=once per game, valid target=you)
  • 1 card(s) · ability/Token · field token: cards moved× +2/+2 Black Zombie (Creature Zombie)filtered tracked set (creature)× +2/+2 Black Zombie (Creature Zombie)
  • 1 card(s) · ability/Token · field token: cards moved× Mutagen (Artifact Mutagen)filtered tracked set (creature)× Mutagen (Artifact Mutagen)
  • 1 card(s) · ability/Unsupported unless clause · removed: Unsupported unless clause
  • 1 card(s) · trigger/Whenever another source you control deals exactly 1 damage to a permanent or pl… · removed: Whenever another source you control deals exactly 1 damage to a permanent or player (active in=battlefield)
  • 1 card(s) · trigger/Whenever you clash and win · removed: Whenever you clash and win (active in=battlefield, optional=yes)
  • 1 card(s) · trigger/YouAttack · added: YouAttack (active in=battlefield, condition=attackers declared ≥ 3, constraint=once per game, valid target=you)
  • 1 card(s) · trigger/YouAttack · removed: YouAttack (active in=battlefield, condition=attackers declared ≥ 3, constraint=once per game, valid target=you)
  • 1 card(s) · ability/base power 2, base toughness 2, add type creature, add subtype Robot, add subty… · field affects: triggering sourcelast created
  • 1 card(s) · ability/base power 2, base toughness 2, add type creature, add subtype Robot, add subty… · field target: last created
  • 1 card(s) · ability/can't · removed: can't (duration=until end of turn)
  • 1 card(s) · ability/conjure · added: conjure
  • 1 card(s) · ability/conjure · removed: conjure
  • 1 card(s) · ability/grant BandsWithOther · removed: grant BandsWithOther (affects=last created, grants=grant BandsWithOther, target=last created)
  • 1 card(s) · ability/grant CumulativeUpkeep · field duration: permanent
  • 1 card(s) · ability/grant Deathtouch · removed: grant Deathtouch (affects=any target, duration=until end of turn, grants=grant Deathtouch, target=any target)
  • 1 card(s) · ability/grant Enchant, grant TotemArmor · removed: grant Enchant, grant TotemArmor (affects=last created, duration=until end of turn, grants=grant Enchant, grants=grant TotemArmor, target=last created)
  • 1 card(s) · ability/grant FirstStrike · field affects: parent targetlast created
  • 1 card(s) · ability/grant FirstStrike · field target: last created
  • 1 card(s) · ability/grant Flash · removed: grant Flash (affects=any target, duration=until end of turn, grants=grant Flash, target=any target)
  • 1 card(s) · ability/grant Flying · field affects: parent targetlast created
  • 1 card(s) · ability/grant Flying · field duration: permanent
  • 1 card(s) · ability/grant Flying · field target: last created
  • 1 card(s) · ability/grant Flying · removed: grant Flying (affects=any target, duration=until end of turn, grants=grant Flying, target=any target)
  • 1 card(s) · ability/grant ability · field affects: triggering sourcelast created
  • 1 card(s) · ability/grant ability, grant Enchant · removed: grant ability, grant Enchant (affects=last created, grants=grant Enchant, grants=grant ability, target=last created)
  • 1 card(s) · ability/grant ability, grant trigger · field duration: permanent
  • 1 card(s) · ability/grant static ability · field duration: permanent
  • 1 card(s) · ability/grant static ability · removed: grant static ability (affects=last created, grants=grant static ability, target=last created)
  • 1 card(s) · ability/grant static ability, grant static ability, grant Enchant · removed: grant static ability, grant static ability, grant Enchant (affects=parent target, grants=grant Enchant, grants=grant static ability, grants=grant static abilit…
  • 1 card(s) · ability/grant trigger · field affects: triggering sourcelast created
  • 1 card(s) · ability/grant trigger · field duration: until end of turnpermanent
  • 1 card(s) · ability/grant trigger, grant Flying, grant Lifelink · field duration: permanent
  • 1 card(s) · cost/two other cards named Say Its Name from your graveyard · added: two other cards named ~ from your graveyard

15 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.
New cards in head: 1.

keloide commented Jul 5, 2026

Copy link
Copy Markdown
Owner Author

Addressed in fa70e1b — added the discriminating owner≠controller test you asked for.

New test: render_silent_binds_to_controller_not_owner_when_they_differ (in crates/engine/tests/integration/render_silent_cant_cast.rs). It puts a spell on the stack owned by P0 (also the Render Silent caster) but controlled by P1, counters it with Render Silent, then asserts:

  • P1 (the countered spell's controller) is barred from casting for the rest of the turn, and
  • P0 (the owner of the countered spell and the caster) remains able to cast.

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 self.controller caster fallback (P0) would restrict P0 and fail the test; only binding to the controller (P1) passes.

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 — zones.rs), so parent_target_controller reads the graveyard object's retained controller. That stays P1 because a stack→graveyard move does not run reset_for_battlefield_exit (which is gated on from == Zone::Battlefield). The new test locks that in — a future stack-exit reset or lookup change that collapsed the scope to the owner would fail it.

Verified locally: cargo test -p engine --test integration -- render_silent → 3 passed / 0 failed; cargo fmt --all clean; parser-combinator gate exit 0 (test-only change). The stale module NOTE was updated to describe the now-covered case.


Generated by Claude Code

keloide commented Jul 5, 2026

Copy link
Copy Markdown
Owner Author

Fixed in fc52094. You're right — the arm was self-contradictory: it documented "fail-closed, restrict no one" but ran debug_assert!(false) first, so a castability query against the reachable unresolved state would panic in debug/test rather than fail closed.

Fix (casting.rs): removed the debug_assert! from the ParentObjectTargetController enforcement arm so it genuinely returns false (restrict no one). Unlike the always-resolved sibling scopes (TargetedPlayer, ScopedPlayer, which route through target_player()'s unwrap_or fallback and can never be stored unresolved), this scope can reachably remain unresolved when there's no object referent — so an unresolved value is a valid fail-closed state, not a bug worth asserting on. Comment updated to say exactly that. (I left the always-resolved siblings' asserts untouched — their invariant still holds.)

Proof test (render_silent_cant_cast.rs): unresolved_parent_object_target_controller_restriction_is_fail_closed now drives the public can_cast_object_now against the stored unresolved restriction and asserts the querying player stays castable — i.e. it restricts no one and does not panic. This test panicked before the assert removal, so it's a genuine guard, not a shape check. The existing parent_object_target_controller_unresolved_without_object_target unit test still covers the stored-state side.

Verified locally:

  • cargo test -p engine --test integration -- render_silent unresolved_parent_object → 4 passed / 0 failed
  • cargo test -p engine --lib -- parent_object_target_controller render_silent → 3 passed / 0 failed
  • cargo fmt --all clean; parser-combinator gate exit 0.

Generated by Claude Code

@keloide keloide force-pushed the claude/phase-developer-track-0stiny branch from fc52094 to 82d5ebf Compare July 6, 2026 08:44

keloide commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Rebased — the branch now contains only the 3 Render Silent commits on top of the PR base (1badc19): 9 files changed, +557/−2. git push --force-with-lease, head is now 82d5ebf.

Root cause of the blast radius: the branch had picked up two Merge branch 'main' commits (e5ccd26, 1595273) that pulled ~40 already-landed upstream PRs into it — e.g. phase-rs#5117 (anaphoric perpetual effects), phase-rs#5101 (stop named filters before origin zones), phase-rs#5080 (protect card-named literals during normalization), phase-rs#5072 (read/write profiler). Those are what re-parsed Curse of Disturbance / BecomeBlocked / Fog Patch / Choking Vines / etc. None of my 3 commits touch combat, block, fog, or name-normalization parsing — the only files they change are the 9 listed in the PR body (all in the RestrictionPlayerScope::ParentObjectTargetController chain + its tests). I dropped those merges and cherry-picked my commits straight onto the base, so the parse-diff baseline (main 1badc1920dab) now sees only this change.

Expected parse-diff after this push: 1 card (Render Silent) — the AddRestriction (duration=until end of turn) signature — and nothing else.

Verified the rebased branch against the base (1badc19), not just the old merged state:

  • cargo test -p engine --test integration -- render_silent unresolved_parent_object → 4 passed / 0 failed
  • cargo fmt --all clean; parser-combinator gate exit 0.

CI is re-running on 82d5ebf.


Generated by Claude Code

claude added 3 commits July 6, 2026 09:13
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
@keloide keloide force-pushed the claude/phase-developer-track-0stiny branch from 82d5ebf to c4af6e9 Compare July 6, 2026 09:20

keloide commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

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 main was 38 commits behind the coverage baseline (preview/coverage-data.json). That split the two checks:

  • Rebasing onto the old base gave a clean parse-diff but failed the required coverage-regression check with 65 spurious "regressions" (Expose the Culprit [shuffle], Vannifar [cloak], Sarkhan [behold], + 64 honesty gaps) — all handlers that only exist in the newer main, not in the stale base. None are touched by this PR.
  • Rebasing onto newer main fixed coverage but re-showed the parser blast radius you flagged.

Fix: fast-forwarded main to current upstream (623d82a, phase-rs#5119) and rebased the 3 Render Silent commits onto it. Now the PR base is current main, so:

  • parse-diff: only Render Silent (AddRestriction (until end of turn)) — no unrelated cards.
  • coverage-regression: Render Silent + Say Its Name GAINED, zero engine regressions.
  • diff back to 9 files / 3 commits / +557 −2.

The 3 commits are unchanged and verified green on this base (cargo test -p engine --test integration -- render_silent unresolved_parent_object → 4/4, fmt clean, parser-combinator gate exit 0). CI is re-running on c4af6e9.


Generated by Claude Code

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants