Skip to content

fix(parser): scale P/T by opponents below half starting life (Anya, Merciless Angel #5920) - #6211

Merged
matthewevans merged 1 commit into
phase-rs:mainfrom
xfodev:fix/anya-opponent-life-count
Jul 19, 2026
Merged

fix(parser): scale P/T by opponents below half starting life (Anya, Merciless Angel #5920)#6211
matthewevans merged 1 commit into
phase-rs:mainfrom
xfodev:fix/anya-opponent-life-count

Conversation

@xfodev

@xfodev xfodev commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

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 to parse_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 existing parse_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:

PlayerCount { PlayerFilter::PlayerAttribute {
  relation: Opponent, attr: LifeTotal(ScopedPlayer),
  comparator: LT, value: DivideRounded(StartingLifeTotal, 2, Down) } }

No new engine variant, no runtime/walker changes. PlayerFilter::PlayerAttribute already exists and its runtime is exercised by shipping cards (Bandit's Talent, Wolfcaller's Howl, Glissa's Retriever). The add-engine-variant gate was run and returned "use the existing parameterized slot" — a scope-baked OpponentLifeBelowHalfStarting sibling 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 (via parse_life_total_comparator + parse_quantity), not just Anya.

Anchored on

  • crates/engine/src/parser/oracle_quantity.rs:1448 — parse_hand_size_who_attr_clause, the sibling tag ∘ comparator ∘ quantity arm this mirrors (same combinator family, same return tuple (QuantityRef, Comparator, QuantityExpr)).
  • crates/engine/src/parser/oracle_quantity.rs:1409 — parse_for_each_opponent_player_attribute_clause, the existing alt(...) this adds one branch to.

Verification

Local cargo test on the full engine test binaries OOMs on this host (7.7 GB; no C toolchain / no root — I link Rust via a bootstrapped zig cc). So I verified by building the engine rlib (non-test builds fine) and running standalone binaries against it:

Parser ASTparse_static_line("~ gets +3/+3 for each opponent whose life total is less than half their starting life total.") produces AddDynamicPower/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):

opponents (life) qualifying Anya P/T
both at 20 (≥ half) 0 4/4 ← the reporter's bug case
one at 9 (< half) 1 7/7
two below half 2 10/10
one at exactly 10 (= half) 0 (strict LT) 4/4

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 in tests/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

Gate A PASS head=2778441f7a61091d4553f04a06b955b9fbafca7c base=45c6b30b85b046ccf5d28741807c95e91356de01

Model: claude-opus-4-8
Thinking: High
Tier: Frontier

…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>
@xfodev
xfodev requested a review from matthewevans as a code owner July 19, 2026 22:52

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@matthewevans matthewevans self-assigned this Jul 19, 2026
@matthewevans matthewevans added the bug Bug fix label Jul 19, 2026
@matthewevans matthewevans removed their assignment Jul 19, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR · 1 card(s), 1 signature(s) (baseline: main 45c6b30b85b0)

🟡 Modified fields (1 signature)

  • 1 card · 🔄 static/Continuous · changed field mods: power +3, toughness +3add dynamic power, add dynamic toughness
    • Affected (first 3): Anya, Merciless Angel

@matthewevans matthewevans self-assigned this Jul 19, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@matthewevans
matthewevans added this pull request to the merge queue Jul 19, 2026
@matthewevans matthewevans removed their assignment Jul 19, 2026
Merged via the queue into phase-rs:main with commit 633ede8 Jul 19, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Anya, Merciless Angel seem to get +3/+3 free — Should only be 'for each opponent with life total less than half their s…

2 participants