Skip to content

fix(parser): keep granted-ability description case when stripping turn conditions#5603

Merged
matthewevans merged 1 commit into
phase-rs:mainfrom
minion1227:minion_5599
Jul 11, 2026
Merged

fix(parser): keep granted-ability description case when stripping turn conditions#5603
matthewevans merged 1 commit into
phase-rs:mainfrom
minion1227:minion_5599

Conversation

@minion1227

Copy link
Copy Markdown
Contributor

Closes #5599

CR 604.1

Bug

A granted ability's description is a serialized, user-visible field (AbilityDefinition.descriptioncard-data.json, rendered in the client). Two compound-subject / self static paths passed the lowercased predicate to strip_suffix_turn_condition and then fed the lowercase result to parse_continuous_modifications / parse_continuous_gets_has, so the granted ability's description leaked the lowercase parse buffer:

  • Brightcap BadgerEach Fungus and Saproling you control has "{T}: Add {G}." rendered its grant as {t}: add {g}. instead of {T}: Add {G}..

Per the issue, the lowercase-mana-symbol leak spans ~40 cards across the pool; the compound-subject anthem path is the reproducing route.

Fix

Pass the original-case predicate to strip_suffix_turn_condition in both leaking callers:

  • parse_subject_continuous_static (anthem.rs)
  • the self-static verb-scan path (dispatch.rs) — the now-redundant predicate_lower is removed.

The trailing condition phrase (during your turn / during turns other than yours) is lowercase in the original text too, so the suffix still matches and the correct StaticCondition is still recovered (regression-tested) — the retained predicate simply keeps its printed case. The third caller (grammar.rs, Hunter's Blowgun . Otherwise path) already passed original-case text and is unchanged; this brings the two leaking callers in line with it and with the sibling handlers, which build predicates from .original.

Tests

  • compound_subject_granted_ability_description_keeps_original_case — Brightcap Badger's compound-subject grant → description == "{T}: Add {G}.".
  • Verified live that a trailing turn condition still strips to DuringYourTurn (the suffix match is case-insensitive to the mixed-case predicate because the phrase itself is lowercase), and single-subject anthems are unchanged.
  • Full parser::oracle_static::tests (1050 passed, 0 failed).

CR verification

  • CR 604.1 — a granted ability functions independently; its printed text (the description) must render faithfully — verified in docs/MagicCompRules.txt.

AI-contributor notes

  • Rules-correct / user-facing: the granted activated ability now displays its printed case; no gameplay change, only the serialized description surface.
  • Class fix: both compound-subject and self static grant paths keep case, matching the already-correct grammar path.
  • Verified: live-parsed Brightcap Badger + a turn-condition predicate before/after; cargo fmt, CI-exact clippy -p engine --all-targets --features proptest -D warnings, and the full oracle_static module are green.

🤖 Generated with Claude Code

…t anthems

Closes phase-rs#5599.

CR 604.1: A granted ability's `description` is a serialized, user-visible field
(`AbilityDefinition.description` → `card-data.json`, rendered in the client).
Two static-parser paths stripped a trailing turn-window condition from the
LOWERCASED predicate and then fed that lowercase buffer to the continuous-
modification parser — so the granted ability's description leaked lowercased.
Brightcap Badger ("Each Fungus and Saproling you control has \"{T}: Add {G}.\"")
rendered its grant as "{t}: add {g}." instead of "{T}: Add {G}.".

Fix: pass the ORIGINAL-case predicate to `strip_suffix_turn_condition` in both
`parse_subject_continuous_static` (anthem.rs) and the self-static predicate arm
(dispatch.rs), matching what the `. Otherwise` grammar path already does. The
condition phrase ("during your turn" / "during turns other than yours") is
lowercase in the original text too, so the suffix still matches and the retained
predicate keeps its printed case. Single-subject anthems ("Tokens you control
have ...") were already correct and are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@minion1227
minion1227 requested a review from matthewevans as a code owner July 11, 2026 19:26
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR · 87 card(s), 122 signature(s) (baseline: main a61e7fdd919c)

8 card(s) · ability/Mana · added: Mana (kind=activated, mana=1 of {W}{U}{B}{R}{G})

Examples: Alpine Moon, Esika, God of the Tree, Gemhide Sliver (+5 more)

8 card(s) · ability/Mana · removed: Mana (kind=activated, mana=1 of {W}{U}{B}{R}{G})

Examples: Alpine Moon, Esika, God of the Tree, Gemhide Sliver (+5 more)

8 card(s) · trigger/Phase · added: Phase (active in=battlefield, constraint=only during your turn, phase=upkeep)

Examples: Aura Flux, Cultist of the Absolute, Disruption Aura (+5 more)

8 card(s) · trigger/Phase · removed: Phase (active in=battlefield, constraint=only during your turn, phase=upkeep)

Examples: Aura Flux, Cultist of the Absolute, Disruption Aura (+5 more)

4 card(s) · trigger/Attacks · added: Attacks (active in=battlefield, condition=life total (max of opponents) ≤ life total (defending player), watches=self)

Examples: Agent of the Shadow Thieves, Guild Artisan, Hardy Outlander (+1 more)

4 card(s) · trigger/Attacks · removed: Attacks (active in=battlefield, condition=life total (max of opponents) ≤ life total (defending player), watches=self)

Examples: Agent of the Shadow Thieves, Guild Artisan, Hardy Outlander (+1 more)

3 card(s) · trigger/ChangesZone · added: ChangesZone (active in=battlefield, to=battlefield, watches=self)

Examples: Candlekeep Sage, Dormant Sliver, Harmonic Sliver

3 card(s) · trigger/ChangesZone · removed: ChangesZone (active in=battlefield, to=battlefield, watches=self)

Examples: Candlekeep Sage, Dormant Sliver, Harmonic Sliver

3 card(s) · ability/DealDamage · added: DealDamage (amount=1, kind=activated, target=any target)

Examples: Cautery Sliver, Street Urchin, Titan of Eternal Fire

3 card(s) · ability/DealDamage · removed: DealDamage (amount=1, kind=activated, target=any target)

Examples: Cautery Sliver, Street Urchin, Titan of Eternal Fire

3 card(s) · ability/Regenerate · added: Regenerate (kind=activated, target=self)

Examples: Clot Sliver, Sedge Sliver, Zombie Master

3 card(s) · ability/Regenerate · removed: Regenerate (kind=activated, target=self)

Examples: Clot Sliver, Sedge Sliver, Zombie Master

2 card(s) · trigger/ChangesZone · added: ChangesZone (active in=battlefield, optional=yes, to=battlefield, watches=self)

Examples: Clash of Realities, Mesmeric Sliver

2 card(s) · trigger/ChangesZone · removed: ChangesZone (active in=battlefield, optional=yes, to=battlefield, watches=self)

Examples: Clash of Realities, Mesmeric Sliver

2 card(s) · ability/DealDamage · added: DealDamage (amount=2, kind=activated, target=any target)

Examples: Acidic Sliver, Psionic Sliver

2 card(s) · ability/DealDamage · removed: DealDamage (amount=2, kind=activated, target=any target)

Examples: Acidic Sliver, Psionic Sliver

2 card(s) · ability/Mana · added: Mana (kind=activated, mana={G})

Examples: Brightcap Badger, Rishkar, Peema Renegade

2 card(s) · ability/Mana · removed: Mana (kind=activated, mana={G})

Examples: Brightcap Badger, Rishkar, Peema Renegade

2 card(s) · ability/Regenerate · added: Regenerate (kind=activated, target=Sliver)

Examples: Crypt Sliver, Poultice Sliver

2 card(s) · ability/Regenerate · removed: Regenerate (kind=activated, target=Sliver)

Examples: Crypt Sliver, Poultice Sliver

1 card(s) · trigger/Attacks · added: Attacks (active in=battlefield, optional=yes, watches=self)

Examples: Grothama, All-Devouring

1 card(s) · trigger/Attacks · added: Attacks (active in=battlefield, watches=self)

Examples: Flaming Fist

1 card(s) · trigger/Attacks · removed: Attacks (active in=battlefield, optional=yes, watches=self)

Examples: Grothama, All-Devouring

1 card(s) · trigger/Attacks · removed: Attacks (active in=battlefield, watches=self)

Examples: Flaming Fist

1 card(s) · ability/BecomeCopy · added: BecomeCopy (duration=permanent, kind=activated, target=another creature)

Examples: Shameless Charlatan

… 97 more signature(s) (97 card-changes) — see parse-diff.json
  • 1 card(s) · ability/BecomeCopy · removed: BecomeCopy (duration=permanent, kind=activated, target=another creature)
  • 1 card(s) · trigger/BecomesTarget · added: BecomesTarget (active in=battlefield, optional=yes, valid source=spell on stack, watches=self)
  • 1 card(s) · trigger/BecomesTarget · removed: BecomesTarget (active in=battlefield, optional=yes, valid source=spell on stack, watches=self)
  • 1 card(s) · ability/Bounce · added: Bounce (kind=activated, target=self)
  • 1 card(s) · ability/Bounce · removed: Bounce (kind=activated, target=self)
  • 1 card(s) · ability/CastFromZone · added: CastFromZone (target=parent target)
  • 1 card(s) · ability/CastFromZone · removed: CastFromZone (target=parent target)
  • 1 card(s) · ability/ChangeZone · added: ChangeZone (kind=activated, target=artifact, to=exile)
  • 1 card(s) · ability/ChangeZone · removed: ChangeZone (kind=activated, target=artifact, to=exile)
  • 1 card(s) · ability/ChangeZoneAll · added: ChangeZoneAll (enters_under=You, from=graveyard, kind=activated, target=in graveyard creature, to=battlefield)
  • 1 card(s) · ability/ChangeZoneAll · removed: ChangeZoneAll (enters_under=You, from=graveyard, kind=activated, target=in graveyard creature, to=battlefield)
  • 1 card(s) · trigger/ChangesZone · added: ChangesZone (active in=battlefield, condition=dealt damage by source this turn, from=battlefield, to=graveyard, watches=creature)
  • 1 card(s) · trigger/ChangesZone · added: ChangesZone (active in=battlefield, from=battlefield, to=graveyard, valid target=player, watches=creature)
  • 1 card(s) · trigger/ChangesZone · added: ChangesZone (active in=battlefield, from=battlefield, to=graveyard, watches=you control artifact or you control creature)
  • 1 card(s) · trigger/ChangesZone · added: ChangesZone (active in=graveyard, from=battlefield, to=graveyard, valid target=player, watches=self)
  • 1 card(s) · trigger/ChangesZone · removed: ChangesZone (active in=battlefield, condition=dealt damage by source this turn, from=battlefield, to=graveyard, watches=creature)
  • 1 card(s) · trigger/ChangesZone · removed: ChangesZone (active in=battlefield, from=battlefield, to=graveyard, valid target=player, watches=creature)
  • 1 card(s) · trigger/ChangesZone · removed: ChangesZone (active in=battlefield, from=battlefield, to=graveyard, watches=you control artifact or you control creature)
  • 1 card(s) · trigger/ChangesZone · removed: ChangesZone (active in=graveyard, from=battlefield, to=graveyard, valid target=player, watches=self)
  • 1 card(s) · ability/Choose · added: Choose (choice=card name, kind=activated, persist=yes)
  • 1 card(s) · ability/Choose · removed: Choose (choice=card name, kind=activated, persist=yes)
  • 1 card(s) · ability/CopyTokenOf · added: CopyTokenOf (copies=token entered this turn you, kind=activated)
  • 1 card(s) · ability/CopyTokenOf · removed: CopyTokenOf (copies=token entered this turn you, kind=activated)
  • 1 card(s) · ability/CreateEmblem · added: CreateEmblem (kind=activated)
  • 1 card(s) · ability/CreateEmblem · removed: CreateEmblem (kind=activated)
  • 1 card(s) · trigger/DamageDoneOnceByController · added: DamageDoneOnceByController (active in=battlefield, damage kind=combat only, valid source=nontoken you control creature, valid target=player)
  • 1 card(s) · trigger/DamageDoneOnceByController · added: DamageDoneOnceByController (active in=battlefield, damage kind=combat only, valid source=you control creature, valid target=player)
  • 1 card(s) · trigger/DamageDoneOnceByController · removed: DamageDoneOnceByController (active in=battlefield, damage kind=combat only, valid source=nontoken you control creature, valid target=player)
  • 1 card(s) · trigger/DamageDoneOnceByController · removed: DamageDoneOnceByController (active in=battlefield, damage kind=combat only, valid source=you control creature, valid target=player)
  • 1 card(s) · trigger/DamageReceived · added: DamageReceived (active in=battlefield, watches=self)
  • 1 card(s) · trigger/DamageReceived · removed: DamageReceived (active in=battlefield, watches=self)
  • 1 card(s) · ability/DealDamage · added: DealDamage (amount=1, kind=activated, target=attacking creature or blocking creature)
  • 1 card(s) · ability/DealDamage · removed: DealDamage (amount=1, kind=activated, target=attacking creature or blocking creature)
  • 1 card(s) · ability/Destroy · added: Destroy (kind=activated, target=permanent)
  • 1 card(s) · ability/Destroy · removed: Destroy (kind=activated, target=permanent)
  • 1 card(s) · ability/Dig · added: Dig (count=4, filter=creature or land, keep_count=1, kind=activated, rest_to=library, reveal=true, to=hand, up_to=true)
  • 1 card(s) · ability/Dig · removed: Dig (count=4, filter=creature or land, keep_count=1, kind=activated, rest_to=library, reveal=true, to=hand, up_to=true)
  • 1 card(s) · ability/Discard · added: Discard (count=1, kind=activated, target=controller)
  • 1 card(s) · ability/Discard · added: Discard (count=1, kind=activated, target=player, timing=sorcery speed)
  • 1 card(s) · ability/Discard · removed: Discard (count=1, kind=activated, target=controller)
  • 1 card(s) · ability/Discard · removed: Discard (count=1, kind=activated, target=player, timing=sorcery speed)
  • 1 card(s) · ability/Draw · added: Draw (kind=activated)
  • 1 card(s) · ability/Draw · removed: Draw (kind=activated)
  • 1 card(s) · ability/FlipCoin · added: FlipCoin (kind=activated, win=yes)
  • 1 card(s) · ability/FlipCoin · removed: FlipCoin (kind=activated, win=yes)
  • 1 card(s) · ability/GainLife · added: GainLife (amount=2)
  • 1 card(s) · ability/GainLife · added: GainLife (amount=3, kind=activated)
  • 1 card(s) · ability/GainLife · added: GainLife (amount=4, kind=activated)
  • 1 card(s) · ability/GainLife · removed: GainLife (amount=2)
  • 1 card(s) · ability/GainLife · removed: GainLife (amount=3, kind=activated)
  • 1 card(s) · ability/GainLife · removed: GainLife (amount=4, kind=activated)
  • 1 card(s) · ability/Mana · added: Mana (kind=activated, mana={B}{B})
  • 1 card(s) · ability/Mana · added: Mana (kind=activated, mana={C} x1)
  • 1 card(s) · ability/Mana · added: Mana (kind=activated, mana={C} x2, restrictions=[XCostOnly])
  • 1 card(s) · ability/Mana · removed: Mana (kind=activated, mana={B}{B})
  • 1 card(s) · ability/Mana · removed: Mana (kind=activated, mana={C} x1)
  • 1 card(s) · ability/Mana · removed: Mana (kind=activated, mana={C} x2, restrictions=[XCostOnly])
  • 1 card(s) · ability/Mill · added: Mill (count=1, kind=activated, target=player)
  • 1 card(s) · ability/Mill · removed: Mill (count=1, kind=activated, target=player)
  • 1 card(s) · trigger/Phase · added: Phase (active in=battlefield, condition=max damage dealt this turn (you -> any target) [by-source] ≥ 5, constraint=only during your turn, phase=end step)
  • 1 card(s) · trigger/Phase · added: Phase (active in=battlefield, condition=you control nontoken creature zone changes this turn (None->Some(Graveyard)) ≥ 1, constraint=only during your turn, pha…
  • 1 card(s) · trigger/Phase · added: Phase (active in=battlefield, constraint=only during your turn, optional=yes, phase=upkeep)
  • 1 card(s) · trigger/Phase · added: Phase (active in=battlefield, constraint=only during your turn, phase=end step)
  • 1 card(s) · trigger/Phase · removed: Phase (active in=battlefield, condition=max damage dealt this turn (you -> any target) [by-source] ≥ 5, constraint=only during your turn, phase=end step)
  • 1 card(s) · trigger/Phase · removed: Phase (active in=battlefield, condition=you control nontoken creature zone changes this turn (None->Some(Graveyard)) ≥ 1, constraint=only during your turn, pha…
  • 1 card(s) · trigger/Phase · removed: Phase (active in=battlefield, constraint=only during your turn, optional=yes, phase=upkeep)
  • 1 card(s) · trigger/Phase · removed: Phase (active in=battlefield, constraint=only during your turn, phase=end step)
  • 1 card(s) · ability/PreventDamage · added: PreventDamage (amount=Next(1), duration=until end of turn, kind=activated, scope=AllDamage, target=any target)
  • 1 card(s) · ability/PreventDamage · removed: PreventDamage (amount=Next(1), duration=until end of turn, kind=activated, scope=AllDamage, target=any target)
  • 1 card(s) · ability/Pump · added: Pump (duration=until end of turn, kind=activated, p/t=+# of Sliver/+0, target=creature Sliver)
  • 1 card(s) · ability/Pump · added: Pump (duration=until end of turn, kind=activated, p/t=+2/+2, target=creature Sliver)
  • 1 card(s) · ability/Pump · removed: Pump (duration=until end of turn, kind=activated, p/t=+# of Sliver/+0, target=creature Sliver)
  • 1 card(s) · ability/Pump · removed: Pump (duration=until end of turn, kind=activated, p/t=+2/+2, target=creature Sliver)
  • 1 card(s) · ability/PutAtLibraryPosition · added: PutAtLibraryPosition (count=Fixed { value: 1 }, position=Top, target=parent target)
  • 1 card(s) · ability/PutAtLibraryPosition · removed: PutAtLibraryPosition (count=Fixed { value: 1 }, position=Top, target=parent target)
  • 1 card(s) · ability/PutCounter · added: PutCounter (counter=1 P1P1, kind=activated, target=self)
  • 1 card(s) · ability/PutCounter · removed: PutCounter (counter=1 P1P1, kind=activated, target=self)
  • 1 card(s) · ability/RemoveCounter · added: RemoveCounter (counter=1 task, kind=activated, target=self)
  • 1 card(s) · ability/RemoveCounter · removed: RemoveCounter (counter=1 task, kind=activated, target=self)
  • 1 card(s) · trigger/SpellCast · added: SpellCast (active in=battlefield, constraint=once per turn, valid target=controller, watches=shares creature type with reference)
  • 1 card(s) · trigger/SpellCast · added: SpellCast (active in=battlefield, valid target=controller)
  • 1 card(s) · trigger/SpellCast · removed: SpellCast (active in=battlefield, constraint=once per turn, valid target=controller, watches=shares creature type with reference)
  • 1 card(s) · trigger/SpellCast · removed: SpellCast (active in=battlefield, valid target=controller)
  • 1 card(s) · ability/Tap · added: Tap (kind=activated, target=permanent)
  • 1 card(s) · ability/Tap · removed: Tap (kind=activated, target=permanent)
  • 1 card(s) · trigger/Taps · added: Taps (active in=battlefield, watches=self)
  • 1 card(s) · trigger/Taps · removed: Taps (active in=battlefield, watches=self)
  • 1 card(s) · ability/Token · added: Token (kind=activated, token=3× +1/+1 White Soldier (Creature Soldier))
  • 1 card(s) · ability/Token · removed: Token (kind=activated, token=3× +1/+1 White Soldier (Creature Soldier))
  • 1 card(s) · trigger/Whenever a mana ability of ~ resolves · added: Whenever a mana ability of ~ resolves (active in=battlefield, constraint=once per turn)
  • 1 card(s) · ability/add · added: add (kind=activated)
  • 1 card(s) · ability/add · removed: add (kind=activated)
  • 1 card(s) · ability/power +2, toughness +0, grant FirstStrike, grant Trample · added: power +2, toughness +0, grant FirstStrike, grant Trample (affects=parent target, duration=until end of turn, grants=grant FirstStrike, grants=grant Trample, gr…
  • 1 card(s) · ability/power +2, toughness +0, grant FirstStrike, grant Trample · removed: power +2, toughness +0, grant FirstStrike, grant Trample (affects=parent target, duration=until end of turn, grants=grant FirstStrike, grants=grant Trample, gr…
  • 1 card(s) · ability/the · added: the
  • 1 card(s) · ability/the · removed: the
  • 1 card(s) · trigger/whenever a mana ability of ~ resolves · removed: whenever a mana ability of ~ resolves (active in=battlefield, constraint=once per turn)

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@matthewevans matthewevans self-assigned this Jul 11, 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 — the right seam, and the pool-level evidence proves it costs nothing. This restores an existing contract rather than special-casing a card, which is why it fixes 87 cards instead of one.

✅ Clean

Right seam — you restored the TextPair contract instead of patching the symptom. Both callees lowercase internally:

let lower = text.to_lowercase();
let tp = TextPair::new(text, &lower);   // parse_continuous_gets_has (anthem.rs:824)
                                        // parse_continuous_modifications (keyword_grant.rs:1230)

Their contract is therefore "hand me original-case text." Passing a pre-lowercased buffer did not merely duplicate work — it destroyed TextPair.original, and that is precisely the field the serialized, user-visible description is built from. That is the whole bug class, and it explains why the leak was invisible to the parse logic yet visible on the card. The third caller (grammar.rs:657) already passed original-case; this brings the two deviants (anthem.rs:312, dispatch.rs:1937) into line with the established idiom rather than inventing anything.

Blast radius verified against the pool, not asserted. The parse-diff sticky reports 87 cards / 122 signatures — larger than the ~40 the PR estimated. I checked its symmetry mechanically: after case-folding, added-only = 0 and removed-only = 0. Every one of the 88 card-changes is a pure case restoration; no ability signature was gained or lost anywhere in the pool. A parser regression would have surfaced as a removal with no matching addition, and there are none. That is the strongest evidence this change could produce.

Discriminating test. compound_subject_granted_ability_description_keeps_original_case asserts description == "{T}: Add {G}.", which fails on pre-fix code with "{t}: add {g}.". It pins the defect rather than restating behavior.

🟡 Non-blocking — a note on the CR citation

CR 604.1 grep-verifies as real ("Static abilities do something all the time rather than being activated or triggered. They are written as statements, and they're simply true"), and it is fine where it sits — it rides along on the pre-existing turn-condition-stripping comment, which is static-ability territory.

But it does not actually govern what this PR fixes. No CR rule mandates the character case of a serialized description — that is a display-fidelity concern, not a rules concern. The real justification is the TextPair original/lowercase contract, which is an engineering invariant of this codebase. The PR body over-claims slightly when it says CR 604.1 requires faithful rendering. Worth internalizing, because a correct-looking CR citation attached to the wrong proposition is how a misapplied rule survives review: grepping proves a rule exists, only tracing proves it governs.

Recommendation: approve and enqueue. Nothing blocking.

@matthewevans matthewevans added the bug Bug fix label Jul 11, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 11, 2026
@matthewevans matthewevans removed their assignment Jul 11, 2026
Merged via the queue into phase-rs:main with commit 76b2472 Jul 11, 2026
13 checks passed
@minion1227
minion1227 deleted the minion_5599 branch July 18, 2026 06:12
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.

Granted-ability description loses case: Brightcap Badger renders "{t}: add {g}." instead of "{T}: Add {G}."

2 participants