fix(parser): scale P/T by opponents below half starting life (Anya, Merciless Angel #5920) - #6211
Conversation
…erciless Angel phase-rs#5920) "Anya gets +3/+3 for each opponent whose life total is less than half their starting life total." The static "gets +N/+N for each opponent whose ..." dynamic-count path had no life-total arm in `parse_for_each_opponent_player_attribute_clause`, so the for-each clause failed and the boost fell through to a FIXED +3/+3 — Anya was buffed unconditionally (reporter saw +3/+3 while every opponent was well above half their starting life). Adds `parse_life_total_who_attr_clause`, the direct sibling of `parse_hand_size_who_attr_clause`, composing the existing shared building blocks `parse_life_total_comparator` (life-total ordering grammar) and `nom_quantity::parse_quantity` (threshold grammar) — the exact pair the existential "an opponent's life total is <cmp> <qty>" static condition uses. The clause now lowers to `PlayerCount { PlayerFilter::PlayerAttribute { LifeTotal LT DivideRounded(StartingLifeTotal, 2, Down) } }`, whose runtime is already wired and exercised by shipping cards (Bandit's Talent etc.) — no new engine variant, no runtime/walker changes. Covers the class "for each opponent whose life total is <comparator> <quantity>" (any comparator × threshold), not just Anya. CR 119. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements parsing for player attribute clauses checking life totals (e.g., "whose life total is ") in oracle_quantity.rs, resolving issue #5920. This allows cards like Anya, Merciless Angel to correctly scale their power and toughness based on opponents' life totals. The implementation includes comprehensive integration tests verifying the behavior under various life total thresholds. The changes are highly idiomatic, use nom combinators correctly, and include proper CR annotations. I have no feedback or issues to raise.
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.
Parse changes introduced by this PR · 1 card(s), 1 signature(s) (baseline: main
|
matthewevans
left a comment
There was a problem hiding this comment.
Approved after current-head review: the parse diff is the claimed Anya dynamic P/T correction, and the runtime test exercises zero, one, and two qualifying opponents plus the strict-half boundary.
Fixes #5920.
Summary
Anya, Merciless Angel (
{3}{R}{W}, 4/4): "Anya gets +3/+3 for each opponent whose life total is less than half their starting life total." She was being buffed unconditionally — the reporter saw +3/+3 while every opponent was well above half their starting life.Root cause: the static "gets +N/+N for each opponent whose …" dynamic-count dispatch (
parse_for_each_opponent_player_attribute_clause) had no life-total arm. The for-each clause failed to parse, so control fell through toparse_continuous_modifications, which extracted a fixed +3/+3 and silently dropped the count scaling.Fix (parser-only)
Adds
parse_life_total_who_attr_clause— the direct sibling of the existingparse_hand_size_who_attr_clause— composing two shared building blocks already used by the existential "an opponent's life total is<cmp><qty>" static condition (i.e. Anya's own second/indestructible ability):parse_life_total_comparator(the shared life-total ordering-comparator grammar)nom_quantity::parse_quantity(the shared threshold-quantity grammar;"half their starting life total"→DivideRounded(StartingLifeTotal, 2, Down))The clause now lowers to:
No new engine variant, no runtime/walker changes.
PlayerFilter::PlayerAttributealready exists and its runtime is exercised by shipping cards (Bandit's Talent, Wolfcaller's Howl, Glissa's Retriever). Theadd-engine-variantgate was run and returned "use the existing parameterized slot" — a scope-bakedOpponentLifeBelowHalfStartingsibling would violate "Parameterize, don't proliferate."Class, not card
Covers "for each opponent whose life total is
<comparator><quantity>" for the full comparator × threshold matrix (viaparse_life_total_comparator+parse_quantity), not just Anya.Anchored on
parse_hand_size_who_attr_clause, the siblingtag ∘ comparator ∘ quantityarm this mirrors (same combinator family, same return tuple(QuantityRef, Comparator, QuantityExpr)).parse_for_each_opponent_player_attribute_clause, the existingalt(...)this adds one branch to.Verification
Local
cargo teston the full engine test binaries OOMs on this host (7.7 GB; no C toolchain / no root — I link Rust via a bootstrappedzig cc). So I verified by building the engine rlib (non-test builds fine) and running standalone binaries against it:Parser AST —
parse_static_line("~ gets +3/+3 for each opponent whose life total is less than half their starting life total.")producesAddDynamicPower/AddDynamicToughness { Multiply{ 3, PlayerCount{ PlayerAttribute{ Opponent, LifeTotal(ScopedPlayer), LT, DivideRounded(StartingLifeTotal,2,Down) } } } }(both P and T, affected SelfRef). The sibling"who has one or fewer cards in hand"clause still parses (no arm shadowing).Runtime P/T — Anya (base 4/4) on the battlefield,
evaluate_layers, starting_life 20 (half 10):Tests added
crates/engine/tests/integration/anya_merciless_angel_5920.rs— the runtime P/T matrix above through the real Oracle parse → static synthesis → layer pipeline (registered intests/integration/main.rs).oracle_static/tests.rs::static_for_each_opponent_below_half_starting_life_is_dynamic_anya— asserts the dynamic AST (revert-failing: pre-fix emits a fixed pump).Gate A
Model: claude-opus-4-8
Thinking: High
Tier: Frontier