feat(engine): count opponents dealt any damage this turn (Furious Spinesplitter, You've Been Caught Stealing)#5144
Conversation
…nesplitter, You've Been Caught Stealing)
"for each opponent who was dealt damage this turn" (any damage — combat OR
noncombat) was swallowed, defaulting the quantity to Fixed 1. Only the combat
form ("dealt combat damage this turn", Tymna the Weaver) was supported.
Parameterize the existing PlayerFilter variant rather than adding a sibling or a
bool (CLAUDE.md "parameterize, don't proliferate"):
- Rename PlayerFilter::OpponentDealtCombatDamage { source } ->
OpponentDealtDamage { kind: DamageKindFilter, source }, where DamageKindFilter
is the pre-existing { Any, CombatOnly, NoncombatOnly } axis already used by the
DamageDealtThisTurn resolver. serde alias + a CombatOnly default preserve
legacy data (a bare OpponentDealtCombatDamage means combat-only, not Any).
- Parser: "dealt {combat|noncombat|} damage this turn" -> the matching kind via a
nom alt; "dealt combat damage" still maps to CombatOnly (Tymna unchanged).
- Resolver: delegate the is_combat decision to the existing
damage_record_matches_kind; noncombat player damage is already recorded in
state.damage_dealt_this_turn (is_combat = false), so no new tracking.
Also fixes the token "for each" arm (oracle_effect): "You create a token for each
..." did not strip the "you" subject before try_parse_token, stranding the
for-each quantity (would have swallowed the combat form too). The token arm now
tries the raw base first, then a subject-stripped fallback, mirroring the numeric
arm. Additive — verified against card-data that no other create-token-for-each
card changes owner/scope.
Flips Furious Spinesplitter and You've Been Caught Stealing to supported;
Tymna the Weaver re-emits kind: CombatOnly, unchanged.
CR 120.1 + CR 120.2a + CR 120.2b + CR 510.1 + CR 120.9.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
There was a problem hiding this comment.
Code Review
This pull request refactors PlayerFilter::OpponentDealtCombatDamage to PlayerFilter::OpponentDealtDamage to support filtering by combat, noncombat, or any damage in compliance with CR 120.2a/120.2b. It updates the parser, quantity resolver, replacement effects, and triggers, and adds comprehensive tests for cards like 'Furious Spinesplitter' and 'You've Been Caught Stealing'. A critical compilation issue was identified in crates/engine/src/game/effects/deal_damage.rs where kind is passed as a reference instead of being dereferenced to a value.
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.
| state, | ||
| p.id, | ||
| source_controller, | ||
| kind, |
There was a problem hiding this comment.
[HIGH] Type mismatch in opponent_dealt_damage_matches call. Evidence: crates/engine/src/game/effects/deal_damage.rs:1670.\n\nWhy it matters: Passing a reference &DamageKindFilter where a value DamageKindFilter is expected will cause a compilation error.\n\nSuggested fix: Dereference kind as *kind.
| kind, | |
| *kind, |
References
- Idiomatic Rust — uses the type system, ownership model, and standard library idioms to their fullest. (link)
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] Current head does not compile in the player collection path. Evidence: crates/engine/src/game/effects/deal_damage.rs:1670 passes kind from PlayerFilter::OpponentDealtDamage { kind, ref source } into opponent_dealt_damage_matches, while that helper takes DamageKindFilter by value at crates/engine/src/game/quantity.rs:4525; the sibling resolver path already calls it with *kind. Why it matters: this blocks the Rust lint/build job before the behavior can be accepted. Suggested fix: pass *kind at the deal_damage.rs:1670 call site.
Also holding approval until the current-head parse-diff sticky is available; at review time card-data/coverage was still running and the required <!-- coverage-parse-diff --> evidence was absent.
Dismissed by maintainer automation: the compile finding was incorrect because this match arm owns PlayerFilter, so kind is a DamageKindFilter value, not a borrowed value.
Parse changes introduced by this PR · 6 card(s), 8 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
Approved. I re-checked the current head after the parse-diff landed: the damage-kind parameterization is at the right quantity/player-filter seam, the runtime tests discriminate Any damage from both Fixed 1 and CombatOnly, and the parse-diff matches the intended token/counter quantity changes without an unexplained scope loss.
matthewevans
left a comment
There was a problem hiding this comment.
I need to pull back the approval after an adversarial pass found a real parser-scope risk in the token-for-each fallback.
[MED] Subject-bearing token-for-each clauses can be accepted with the wrong token creator. Evidence: crates/engine/src/parser/oracle_effect/mod.rs now falls back from raw token parsing to subject::strip_subject_clause(base_no_duration) before calling token::try_parse_token. try_parse_token defaults Effect::Token.owner to TargetFilter::Controller, and the early return from try_parse_for_each_effect only reapplies optional/condition slots before returning. It does not run the later subject-target/recipient rebinding machinery that normally lifts a subject like each player or target opponent into the token owner.
Why this matters: the fallback needed for You create a Treasure token for each ... is good, but as written it can also make broader subject forms parse as supported while creating the tokens for the source controller. Please either limit this fallback to the you create ... form needed here, or route the parsed token through the existing subject/player-scope rebinding path and add a regression for a non-controller token creator.
Summary
Supports "for each opponent who was dealt damage this turn" (ANY damage — combat OR noncombat) on Furious Spinesplitter and You've Been Caught Stealing. The quantity was swallowed →
Fixed 1; only the combat form ("dealt combat damage this turn", Tymna the Weaver) was supported.Parameterize, don't proliferate (per
CLAUDE.md) — reuse the existing typed axis, no new variant/bool:PlayerFilter::OpponentDealtCombatDamage { source }→OpponentDealtDamage { kind: DamageKindFilter, source }, whereDamageKindFilteris the pre-existing{ Any, CombatOnly, NoncombatOnly }enum already threaded through theDamageDealtThisTurnresolver. A#[serde(alias)]+ aCombatOnlydefault preserve legacy data (a bareOpponentDealtCombatDamagemeant combat-only, notAny)."dealt {combat|noncombat|} damage this turn"→ the matching kind via a nomalt;"dealt combat damage"still →CombatOnly(Tymna unchanged).is_combatdecision to the existingdamage_record_matches_kind. Noncombat player damage is already recorded instate.damage_dealt_this_turn(is_combat = false), so no new tracking infrastructure.Files changed
crates/engine/src/types/ability.rs(variant parameterization + serde back-compat)crates/engine/src/parser/oracle_quantity.rs(kind-aware clause combinator)crates/engine/src/parser/oracle_effect/mod.rs(token-for-each subject-strip fallback — see Scope Expansion)crates/engine/src/game/quantity.rs(resolver viadamage_record_matches_kind)crates/engine/src/game/{effects/mod.rs, effects/deal_damage.rs, effects/speed_effects.rs, ability_scan.rs, ability_rw.rs, triggers.rs, coverage.rs, replacement.rs}(rename threaded through exhaustive matches / doc)crates/engine/src/parser/{oracle_trigger.rs, oracle_trigger_tests.rs, swallow_check.rs}crates/engine/tests/furious_spinesplitter_any_damage.rs,crates/engine/tests/you_have_been_caught_stealing_any_damage.rs(new)CR references
CR 120.1— dealing damageCR 120.2a— combat damage;CR 120.2b— noncombat damage (the newkindaxis)CR 510.1— combat damage step;CR 120.9— "damage dealt by a source" restrictionTrack
Developer
LLM
Model: claude-opus-4-8
Thinking: high
Tier: Frontier
Verification
cargo fmt --all— clean./scripts/check-parser-combinators.sh— clean (exit 0)cargo check -p engine --all-targets— clean (exhaustivePlayerFiltermatches all threaded)cargo clippy -p engine --all-targets -- -D warnings— cleancargo test -p engine— 0 failures (incl. updated + new parser/resolver/serde tests and both new runtime tests)./scripts/gen-card-data.sh+cargo coverage— Furious Spinesplitter & You've Been Caught Stealing:supported: true,gap_count: 0; Tymna the Weaver: unchanged (kind: CombatOnly)cargo semantic-audit— 0 findings for all three cardsTests:
AnyandNoncombatOnlyforms →PlayerCount { OpponentDealtDamage { kind } }; combat form stillCombatOnly.Any→ 2,CombatOnly→ 1,NoncombatOnly→ 1 (kind-discriminating). Legacy-JSON test:{"type":"OpponentDealtCombatDamage"}(nokind) →CombatOnly; round-trip.you_have_been_caught_stealing_any_damage.rs— Bribe mode (modes(&[1])) → 2 Treasure tokens. Pre-fixFixed 1→ 1;CombatOnly→ 0. Reach-guard: parse =OpponentDealtDamage{Any}, noUnimplemented.furious_spinesplitter_any_damage.rs— end-step trigger → 2+1/+1counters (read viarunner.state().objects[...].counters). Same revert-failing/reach-guard pattern.Gate A
./scripts/check-parser-combinators.sh— exit 0, no violations.Anchored on
crates/engine/src/types/ability.rs—DamageKindFilter(existing{Any,CombatOnly,NoncombatOnly}) reused as thekindaxis, mirroring its use in theDamageDealtThisTurnresolver.crates/engine/src/game/quantity.rsdamage_record_matches_kind— the existing per-record kind gate the resolver now delegates to (was a hardcodedr.is_combat).crates/engine/src/parser/oracle_effect/mod.rs— the token-for-each arm's new raw-then-subject-stripped ordering mirrors the pre-existing numeric-for-each arm directly above it.Scope Expansion
The enum parameterization alone did not flip You've Been Caught Stealing: the token "for each" arm (
try_parse_for_each_effect→try_parse_token,oracle_effect/mod.rs) fed the raw subject-bearing base"You create a Treasure token"totry_parse_token, which returnedNone, stranding the correctly-parsed for-each quantity (this also swallowed the combat token form). Fixed by having the token arm try the raw base first, then fall back to a subject-stripped base — mirroring the numeric arm directly above. Verified additive: sweptcard-data.jsonfor everycreate … token for each …card; multi-player subjects (each player/opponent creates …) are handled byplayer_scopeupstream and never reach this fallback, distributiveits controller creates …routes elsewhere, and the only newly-captured forms are singularyou create …(owner = controller, correct). No card changes owner/scope.Validation Failures
None.
CI Failures
None.