feat(engine): coerced-attack + end-step punisher (Siren's Call, Maddening Imp)#5220
Conversation
…ning Imp)
Adds the "creatures the active player controls attack this turn if able,
then destroy the non-attackers at the next end step" class.
- New ControllerRef::ActivePlayer (CR 102.1) — the turn player; resolved live
from state.active_player. Distinct from Opponent (which matches every
opponent in multiplayer). Threaded through the exhaustive ControllerRef sweep.
- New FilterProp::ControlledContinuouslySinceTurnBegan (CR 302.6 / 508.1a) —
haste-independent control-continuity predicate (raw summoning_sick), for
Siren's Call's "didn't control continuously since the turn began" exemption.
- Parser: recognize "the active player controls/controlled" as a subject
controller (parse_zone_controller + past-tense suffix); a post-lowering
card-assembly pass binds Siren's Call's delayed DestroyAll controller and
re-binds Maddening Imp's "those creatures" anaphor to the concrete filter.
- Parser: compound activation-timing recognition ("during an opponent's turn
and only before combat") reusing the existing RequiresCondition{Not(IsYourTurn)}
+ BeforeAttackersDeclared primitives — no new variant; also fixes Nettling Imp
and removes a verbatim-string hack.
Maddening Imp's "before combat" enforcement depends on the is_before_attackers_declared
window fix (separate PR).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements support for the active player role (ControllerRef::ActivePlayer) and the control continuity predicate (FilterProp::ControlledContinuouslySinceTurnBegan) to correctly handle cards like Siren's Call and Maddening Imp, along with adding comprehensive tests. The reviewer feedback highlights opportunities to improve composability by avoiding hardcoded "Wall" subtype restrictions in general lowering helpers, and to ensure strict rule fidelity by not restricting control continuity checks solely to creatures. Additionally, the reviewer notes that several rules-touching boilerplate match arms are missing mandatory CR annotations.
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.
| | FilterProp::InAnyZone { .. } | ||
| | FilterProp::WasDealtDamageThisTurn | ||
| | FilterProp::EnteredThisTurn | ||
| | FilterProp::ControlledContinuouslySinceTurnBegan |
There was a problem hiding this comment.
[MEDIUM] Ensure all rules-touching boilerplate match arms carry CR annotations. Evidence:
crates/engine/src/game/ability_rw.rs:2334.
Why it matters: Every rules-touching line of engine code must carry a verified CR annotation to maintain strict fidelity and auditability of rule implementations (this also applies to line 2579).
Suggested fix: Add a comment referencing CR 302.6.
| | FilterProp::ControlledContinuouslySinceTurnBegan | |
| // CR 302.6: control continuity. | |
| | FilterProp::ControlledContinuouslySinceTurnBegan |
References
- CR annotations are mandatory and verified. Every rules-touching line of engine code must carry a comment of the form CR : . (link)
There was a problem hiding this comment.
Left as-is. These arms (ability_rw.rs:2334/2579) are RwProfile plumbing — they group the variant with the other temporal FilterProps (EnteredThisTurn, AttackedThisTurn, ZoneChangedThisTurn, …) for read/write classification, not rule implementation. Per CLAUDE.md ("Do not annotate boilerplate, serialization, or plumbing — only code that implements a game rule"), none of the sibling variants in these arms are CR-annotated; the rule-implementing eval arm in filter.rs carries the CR 302.6 / 508.1a annotation.
🤖 Addressed by Claude Code
| | FilterProp::InAnyZone { .. } | ||
| | FilterProp::WasDealtDamageThisTurn | ||
| | FilterProp::EnteredThisTurn | ||
| | FilterProp::ControlledContinuouslySinceTurnBegan |
There was a problem hiding this comment.
[MEDIUM] Ensure all rules-touching boilerplate match arms carry CR annotations. Evidence:
crates/engine/src/game/filter.rs:216.
Why it matters: Every rules-touching line of engine code must carry a verified CR annotation to maintain strict fidelity and auditability of rule implementations (this also applies to lines 440, 3132, and 4629).
Suggested fix: Add a comment referencing CR 302.6.
| | FilterProp::ControlledContinuouslySinceTurnBegan | |
| // CR 302.6: control continuity. | |
| | FilterProp::ControlledContinuouslySinceTurnBegan |
References
- CR annotations are mandatory and verified. Every rules-touching line of engine code must carry a comment of the form CR : . (link)
There was a problem hiding this comment.
Left as-is — same rationale. filter.rs:216/440/3132/4629 are structural grouping arms (the variant listed alongside EnteredThisTurn/AttackedThisTurn/etc. for filterability classification), not rule logic; per CLAUDE.md these plumbing arms are exempt from CR annotation and no sibling variant in them is annotated. The rule-implementing eval arm is annotated (CR 302.6 + 508.1a).
🤖 Addressed by Claude Code
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] The Maddening Imp delayed punisher live-refilters instead of using the objects named by “those creatures.” Evidence: crates/engine/src/parser/oracle.rs:2439 documents the TrackedSet(0) rewrite as a live-refilter deviation, and crates/engine/tests/coerced_attack_punisher.rs:286 asserts that a late-arriving creature is matched even though it was never in the original coerced set. Why it matters: CR 608.2c requires the effect to keep referring to the objects identified as “those creatures” when the ability resolved; this implementation destroys late arrivals, spares control-change-outs, and can re-capture new objects after zone changes. Suggested fix: publish a real tracked set/snapshot when the mass MustAttack effect resolves and make the delayed DestroyAll consume that tracked population, applying the non-Wall and did-not-attack predicates without reselecting a fresh population.
[MED] The Siren’s Call runtime test bypasses the cast path even though the restriction it cites is now phase-only and should admit a non-active caster. Evidence: crates/engine/tests/coerced_attack_punisher.rs:580 says cast().resolve() is unreachable because BeforeAttackersDeclared requires the priority seat to be active, but crates/engine/src/game/restrictions.rs:1925 now makes that gate a pure phase check and crates/engine/src/game/restrictions.rs:4162 tests non-active casters. Why it matters: this PR claims card support and changes casting/activation restrictions, but the main runtime test skips the production casting pipeline that would prove the parsed spell can actually be cast and resolved legally. Suggested fix: drive Siren’s Call through GameRunner::cast(...).resolve() on an opponent’s precombat/begin-combat priority window, or remove the stale premise and add the missing front-door coverage for the remaining blocker.
I also could not approve this while the required parse-diff evidence is absent for the current engine/parser head; the current checks still show the card-data/coverage job pending, so the sticky parse-diff may simply not have posted yet.
Parse changes introduced by this PR · 3 card(s), 5 signature(s) (baseline: main
|
…ntinuity predicate Addresses code-review feedback on the coerced-attack punisher: - Maddening Imp's "those creatures" punisher filter is now derived from the coerce clause's own `affected` filter (new `coerce_affected_filter` helper) instead of a hardcoded `Non(Wall)` — so it carries whatever subtype restriction the card's coerce text specifies, keeping the class composable. - `FilterProp::ControlledContinuouslySinceTurnBegan` now evaluates as `!obj.summoning_sick` (dropped the creature-only restriction). The `summoning_sick` continuity flag is set on ETB/control-change for every permanent and cleared at the controller's turn start (CR 302.6 / 508.1a), so it is a general per-permanent property. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Maddening Imp still live-refilters “those creatures” instead of snapshotting the objects named as the ability resolves. Evidence: crates/engine/src/parser/oracle.rs:2459 documents the rebound punisher as live-refiltered, and crates/engine/tests/coerced_attack_punisher.rs:307 asserts a late-arriving creature is matched even though it was never among “those creatures.” Why it matters: CR 603.7c / CR 608.2c require the delayed trigger to keep referring to the objects named by the resolving ability, so this will over-include late arrivals, under-include creatures that changed controller, and re-capture new objects after zone changes. Suggested fix: create a snapshot/tracked-set producer for the MustAttack affected objects at ability resolution, then have the delayed DestroyAll consume that frozen set with the “didn’t attack this turn” filter at end step.
…atures" (CR 608.2c) Addresses maintainer review on phase-rs#5220. [HIGH] Maddening Imp's delayed punisher was live-refiltering "those creatures" instead of the objects named as the ability resolved. Now the mass MustAttack coerce publishes a frozen tracked set of the affected population at resolution, and the delayed DestroyAll consumes it as `TrackedSetFiltered{Not(AttackedThisTurn)}`: - parser: `is_mass_coerce_static` marks the coerce as a tracked-set publisher, and the "each of those <noun> that <predicate>" anaphor folds the trailing predicate into `TrackedSetFiltered{id:0, ...}`. - runtime: a filter-driven `GenericEffect` arm in `affected_objects_from_events` re-enumerates the coerced population (via the shared `generic_effect_application_filter`) and publishes it; the existing two-way gate (`needs_tracked_set` / `next_sub_needs_tracked_set`) keeps a standalone mass MustAttack from publishing. - `bind_tracked_set_to_effect` gains a `DestroyAll` arm that pins the sentinel `TrackedSetId(0)` to the concrete id at delayed-trigger creation (reusing `TargetFilter::rebind_tracked_set_sentinel`), so end-step resolution reads the frozen set, not a live `max_by_key` scan that a later tracked set could hijack. Correct on all three CR 608.2c/603.7c/400.7 cases: late arrivals excluded, control-change-outs still destroyed, re-entered objects excluded. Deletes the old live-refilter route + its 3-direction deviation note. Siren's Call route unchanged (it re-specifies the population explicitly, not via "those creatures"). [MED] Siren's Call test now drives the real `GameRunner::cast(...).resolve()` front door (reachable since the before-attackers window became phase-only in phase-rs#5192), replacing the stale "cast infeasible" premise. New tests: frozen late-arrival spared, control-change-out destroyed, and a cross-resolution collision test (a higher-id tracked set published between {T} and end step does not hijack the punisher). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Both review findings addressed in b6a7308 (rebased on current main). [HIGH] — frozen "those creatures" snapshot. Maddening Imp no longer live-refilters. The mass [MED] — Siren's Call real cast path. The Test A premise was stale: since #5192 made the before-attackers window phase-only, a non-active caster can cast it front-door. Test A now drives the real Verified on the current-main merge: full 🤖 Addressed by Claude Code |
matthewevans
left a comment
There was a problem hiding this comment.
[LOW] The branch includes unrelated generated changelog state. Evidence: client/public/changelog.json:1, client/public/changelog-meta.json:2, and scripts/changelog/state.json:2 add release id 165 for “Morph face-down casting arrives,” which is unrelated to the coerced-attack punisher engine/parser work. Why it matters: this PR should only ship the Siren's Call / Maddening Imp class; carrying release metadata can publish or overwrite changelog state independently of the release process. Suggested fix: remove the changelog files from this branch.
|
[LOW] changelog files — already resolved at the current head (b6a7308). The
So there's nothing to remove (deleting them would instead propose removing 🤖 Addressed by Claude Code |
matthewevans
left a comment
There was a problem hiding this comment.
Maintainer sign-off: current-head review clean; proof, parse-diff, runtime tests, CI, and queue gates verified.
Summary
Implements the coerced-attack + end-step punisher class — "creatures the active player controls attack this turn if able, then destroy the ones that didn't at the next end step":
Engine primitives added (2 new variants, both gated via /add-engine-variant)
ControllerRef::ActivePlayer(CR 102.1) — the turn player, resolved live fromstate.active_player. Distinct fromOpponent(which matches every opponent in multiplayer); a leaf sibling ofDefendingPlayer/EnchantedPlayer/TriggeringPlayer. Threaded through the exhaustiveControllerRefmatch sweep.FilterProp::ControlledContinuouslySinceTurnBegan(CR 302.6 / 508.1a) — haste-independent control-continuity predicate (rawsummoning_sick), distinct from the haste-coupledHasHasteOrControlledSinceTurnBegan. Powers Siren's Call's "didn't control continuously since the turn began" exemption.Parser
parse_zone_controller+ the past-tense suffix).DestroyAllcontroller toActivePlayer(its clause parses as a separate ability, so scope can't cross the line boundary) and re-binds Maddening Imp's "those creatures" anaphor to the concrete filter.RequiresCondition{Not(IsYourTurn)}+BeforeAttackersDeclaredprimitives (no new variant), which also fixes Nettling Imp (same compound-phrase gap) and lets a 25-line verbatim-string hack be deleted (subsumed by the general parser; 27 legacy "during your turn, before attackers" cards keep working).Known deviation (disclosed)
Maddening Imp "those creatures" is re-bound to a live filter
Typed{Creature, Non(Wall), controller:ActivePlayer, Not(AttackedThisTurn)}rather than a frozen population snapshotted at resolution (there is no tracked-set producer for forced attackers). This diverges from CR 608.2c in three narrow directions — late-arrival over-inclusion, control-change-out under-inclusion, left-and-re-entered re-capture — all documented in-code and pinned by a test. A faithful frozen-population producer is a single-swap-site follow-up I'm happy to do if preferred.Anchored on
ControllerRef::EnchantedPlayer/DefendingPlayer: the game-defined-player-role family the newActivePlayervariant joins.controller_ref_player(exhaustive, no wildcard): the single resolution seam extended withActivePlayer => Some(state.active_player).synthesize_etb_exile_ltb_return_pair: the post-lowering cross-ability reconciliation pass the newbind_active_player_punisher_targetpass mirrors.parse_zone_controller: the controller-suffix combinator extended with theActivePlayerarm.Test Plan
New
crates/engine/tests/coerced_attack_punisher.rs(17 tests) + one in-crate unit test. Drives the real parse + filter/resolution runtime; every negative has a positive reach-guard and fails on revert of its fix. Highlights:ControllerRef::ActivePlayerresolves live;Opponentdiscriminator matches multiple players in 3-player.ControlledContinuouslySinceTurnBeganis haste-independent (discriminates from the haste-coupled sibling).Verification (Non-developer track; Tilt down → direct cargo)
cargo test -p engine— 203 test binaries, 0 failures (incl. 17 new), rebased on currentmaincargo check -p engine-wasm --target wasm32-unknown-unknown— exit 0cargo check -p mtgish-import— exit 0 (the one additive strict-failControllerRef::ActivePlayerarm; no mtgish logic)cargo fmt --all— cleanPre-existing, unrelated:
cargo check -p engine --features forgefails with 16 errors (ManaProduction.contribution,Effectmissing fields,SacrificeCost, …) — verified pre-existing on upstream/main via a clean-base build; this diff touches zero forge files (forge uses string-keyed construction, no exhaustiveControllerRef/FilterPropmatch).Model: claude-opus-4-8
Thinking: high
Tier: Frontier
Track: Non-developer (card-data.json integration deferred to CI per docs/AI-CONTRIBUTOR.md §2)