Fix Veteran Bodyguard / Weathered Bodyguards — dropped tap-gate + unblocked/combat source restriction on damage redirection - #5518
Conversation
…n damage redirection (Veteran Bodyguard, Weathered Bodyguards)
Veteran Bodyguard ("As long as this creature is untapped, all damage
that would be dealt to you by unblocked creatures is dealt to this
creature instead.") and Weathered Bodyguards (same shape, scoped to
combat damage) parsed to an unconditional, unrestricted redirection —
dropping the leading "as long as untapped" gate (CR 604.2), the "by
unblocked creatures" source filter (CR 509.1h), and the combat-only
damage scope.
parse_damage_redirection_replacement now parses all three, reusing
existing types verbatim: ReplacementCondition::SourceTappedState,
FilterProp::Unblocked (via parse_damage_source_subject_filter falling
through to parse_type_phrase's existing unblocked-combat-status
recognition), and CombatDamageScope::CombatOnly (via scan_combat_scope).
Renamed strip_as_long_as_prefix_for_prevention to
strip_as_long_as_condition_prefix since it is now shared between the
prevention and redirection parsers.
Palisade Giant is deliberately excluded — its real Oracle text has no
"unblocked" restriction — and has its own regression-guard test proving
the exclusion is correct.
Discovered but out of scope: game/replacement.rs's damage_done_applier
only reads redirect_target inside the ShieldKind::Redirection branch
(CR 614.9); this whole card class parses to ShieldKind::Prevention with
a separate redirect_target field that branch never reads, so the
"instead" creature never actually takes the redirected damage today.
Filed as a follow-up (shared runtime code, wide blast radius); the new
tests document this and assert life-total delta rather than
damage_marked as the discriminator.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMa6DrxXyFHBdGxz3uLgvM
…arse-backlog Fixed by the preceding commit. Root cause #1 (relative-clause / filter restriction on target dropped): 750 -> 748 cards; totals rebased to 4761 distinct / 4795 total appearances. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMa6DrxXyFHBdGxz3uLgvM
There was a problem hiding this comment.
Code Review
This pull request implements parsing support for damage redirection replacement effects with optional source restrictions and tap-state conditions (e.g., Veteran Bodyguard and Weathered Bodyguards), along with corresponding integration and unit tests. Feedback highlights a need to correct a Comprehensive Rules citation for combat damage (CR 120.2a instead of CR 615) and to ensure that the extracted tap-state condition is not ignored in Pattern 3 redirection effects.
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.
|
|
||
| // CR 614.9 + CR 509.1h: optional "by <source>" scope-restriction. | ||
| let source_filter = parse_damage_redirection_source_clause(working_lower); | ||
| // CR 615: optional "combat damage" qualifier scopes to combat damage only. |
There was a problem hiding this comment.
The comment cites CR 615 (Prevention Effects) for the 'combat damage' qualifier, but combat damage is defined under CR 120.2a (or more generally CR 510). To adhere to rule R6, please update the citation to reference the correct section of the Comprehensive Rules.
| // CR 615: optional "combat damage" qualifier scopes to combat damage only. | |
| // CR 120.2a: optional "combat damage" qualifier scopes to combat damage only. |
References
- CR annotations are mandatory and verified (Rule R6). (link)
| if nom_primitives::scan_contains(working_lower, "would deal damage to you") | ||
| && nom_primitives::scan_contains(working_lower, "prevent that damage") |
There was a problem hiding this comment.
The prefix_condition (extracted from the 'as long as' gate) is currently ignored in Pattern 3. If a card matching Pattern 3 has an 'as long as' condition, the condition will be silently discarded. Consider refactoring Pattern 3 to construct the ReplacementDefinition as a mutable variable and conditionally attach prefix_condition via .condition(cond) before returning.
Parse changes introduced by this PR✓ No card-parse changes detected. |
matthewevans
left a comment
There was a problem hiding this comment.
Approving — this is high-quality work.
Verified:
- Card text Scryfall-verified verbatim for both Veteran Bodyguard and Weathered Bodyguards (and Palisade Giant's current Oracle, correctly noting its recipient-list half is a separate pre-existing bug).
- All six CR annotations grep-verified against the Comprehensive Rules: 604.2 (static continuous/replacement effects), 614.9 (damage-redirection replacement), 509.1h (attacker-with-blockers becomes blocked), 510.1c + 702.19b (trample assignment), 509.1g.
- Building-block reuse — the source clause delegates to the existing
parse_damage_source_subject_filter→parse_type_phrasepath (no new unblocked-detection), and the"as long as"gate lifter is shared cleanly via thestrip_as_long_as_condition_prefixrename rather than duplicated. - Both new-set fields are runtime-live:
damage_source_filter(replacement.rs:4816/5339) andcombat_scope(replacement.rs:4829-4834/5359-5363,CombatOnly if !is_combat) are consumed by the resolver — not inert. The blocked-trample integration test is a genuine end-to-end discriminator proving theUnblockedsource filter gates at runtime.
On the disclosed gap: your honesty about redirect_target: SelfRef being dead data for the whole prevent-only class (ShieldKind::Prevention never redirects — Pariah/Palisade Giant included) is exactly right and appreciated. That's a pre-existing, class-wide runtime-resolver gap; this parser-scoped PR doesn't touch or worsen it, and the in-scope restrictions (source/combat/tap-gate) are all consumed and tested. Correct call to defer it as a separate follow-up.
Non-vacuous negative tests with a positive reach-guard, real Oracle text, delegated building blocks — thank you.
…ge-redirection Pattern 3 (phase-rs#5531) Addresses two medium-priority review comments left on the already-merged phase-rs#5518 (Veteran Bodyguard / Weathered Bodyguards tap-gate fix): 1. The "combat damage" qualifier comment in parse_damage_redirection_replacement cited CR 615 (Prevention Effects), which is wrong — combat damage itself is defined under CR 120.2a. Corrected the annotation. 2. Pattern 3 (Pariah's Shield's "if a source would deal damage to you, prevent that damage" shape) built and returned its ReplacementDefinition without ever attaching prefix_condition — the leading "as long as <tap-state>" gate extracted at the top of the function. No shipped card currently matches Pattern 3 with that prefix, so this was latent rather than live-wrong, but it would have silently dropped the gate for any future card that did. Pattern 3 now attaches prefix_condition via .condition(), mirroring Pattern 1/2's existing handling in the same function. Claude-Session: https://claude.ai/code/session_01DMa6DrxXyFHBdGxz3uLgvM Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Summary
Fixes the "Replacement / prevention / 'instead' effect mis-modeled" misparse for Veteran Bodyguard and Weathered Bodyguards.
Both cards use the shape "As long as this creature is untapped, all [combat] damage that would be dealt to you [by unblocked creatures] is dealt to this creature instead." The parser was dropping three things: the leading "as long as untapped" condition gate (CR 604.2), the "by unblocked creatures" source-filter restriction (CR 509.1h), and the "combat damage" vs. plain "damage" scope distinction. Net effect before this fix: both cards redirected all damage to their controller, unconditionally, regardless of tapped state or source — strictly wrong per their printed text.
parse_damage_redirection_replacement(crates/engine/src/parser/oracle_replacement.rs) now parses all three, reusing pre-existing types verbatim — no new enum variants or struct fields anywhere, includingtypes/ability.rs:ReplacementCondition::SourceTappedState { tapped: bool }for the tap-gateFilterProp::Unblockedfor the "unblocked creatures" source restriction (via the pre-existingparse_damage_source_subject_filter→parse_type_phrase→parse_combat_status_prefixchain)CombatDamageScope::CombatOnlyfor Weathered Bodyguards' "combat damage" scope (via the pre-existingscan_combat_scope)Palisade Giant is deliberately excluded from the source-filter class — its real Oracle text has no "unblocked" restriction — and has its own dedicated regression-guard test proving the exclusion.
Also renamed
strip_as_long_as_prefix_for_prevention→strip_as_long_as_condition_prefix, since it is now shared between the prevention and redirection parsers (it was previously only used by the former).Discovered pre-existing bug (out of scope)
While tracing the runtime path, I found that
game/replacement.rs'sdamage_done_applieronly reads the shield'sredirect_targetfield inside itsShieldKind::Redirectionbranch (~line 1190-1193, CR 614.9). This entire card class — Pariah, Palisade Giant, Veteran Bodyguard, Weathered Bodyguards — parses toShieldKind::Preventionwithredirect_target: SelfRefas a separate field. TheShieldKind::Preventionbranch (~line 1336-1349) never readsredirect_targetand just returnsApplyResult::Prevented(~1382-1383). Net effect: this whole "damage is dealt to X instead" class only ever prevents damage to the controller — the "instead" creature never actually takes the redirected damage, can never die from it, and never triggers "dealt damage" abilities off it.This is real, but it's in shared runtime resolver code affecting multiple already-shipped cards and needs its own dedicated plan+review cycle — it is not fixed by this PR, which is scoped to the parser only (tap-gate + source-filter + combat-scope, all upstream of
ShieldKinddispatch and confirmed live/consumed at the replacement-candidate-eligibility gate regardless of this gap). The new tests document this in code comments and intentionally assert the life-total delta rather thandamage_markedon the Bodyguard as their discriminator, so they don't silently mask the gap.Files changed
crates/engine/src/parser/oracle_replacement.rscrates/engine/tests/integration/main.rscrates/engine/tests/integration/veteran_bodyguard_tap_redirect.rs(new)docs/parser-misparse-backlog.mdGate A
Anchored on
crates/engine/src/parser/oracle_replacement.rs:8360— existingparse_damage_prevention_replacement, the direct precedent this PR mirrors for adding a leading "as long as" tap-gate to a damage replacement parser.crates/engine/src/parser/oracle_replacement.rs:5937— existingparse_damage_source_subject_filter, reused as-is (not reimplemented) to resolve the "unblocked creatures" source clause via its existing fallthrough toparse_type_phrase's combat-status recognition.crates/engine/src/parser/oracle_replacement.rs:6268— existingscan_combat_scope, reused as-is to resolve the "combat damage" vs. plain "damage" scope distinction for Weathered Bodyguards.CR references
Track
Developer
LLM
Model: claude-sonnet-5
Thinking: high
Verification
cargo fmt --all— cleancargo clippy -p engine --all-targets -- -D warnings— clean, zero warnings (confirmed post-rebase)./scripts/check-parser-combinators.sh— clean, exit 0cargo test -p engine --lib— 16041 passed, 0 failed, 6 ignored (confirmed post-rebase)cargo test -p engine --test integration -- veteran_bodyguard— 3 passed, 0 failed (confirmed post-rebase): tap-gate positive/negative pair plus a new discriminating test proving a blocked+trampling attacker's excess damage to the defending player is correctly not caught by the shield (the "unblocked creatures" source filter axis)/review-impl— 2 rounds. Round 1 found zero blocking findings but flagged one non-blocking test-coverage gap (the source-filter and combat-scope axes were only proven at the parser-AST level, not through a full combat scenario). Addressed by adding the trample discriminator test. Round 2 (fresh reviewer, fresh context) confirmed clean, independently re-verified all CR citations and traced the new test's production path end-to-end.Scope Expansion
None.
Validation Failures
None.
CI Failures
None.