Skip to content

fix(parser): recognize "~'s loyalty" as a source loyalty-counter quantity (Nissa Ascended Animist)#6045

Merged
matthewevans merged 4 commits into
phase-rs:mainfrom
dhgoal:fix/self-loyalty-quantity-ref-6012
Jul 16, 2026
Merged

fix(parser): recognize "~'s loyalty" as a source loyalty-counter quantity (Nissa Ascended Animist)#6045
matthewevans merged 4 commits into
phase-rs:mainfrom
dhgoal:fix/self-loyalty-quantity-ref-6012

Conversation

@dhgoal

@dhgoal dhgoal commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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

Closes #6012.

Summary

Nissa, Ascended Animist — "[+1]: Create an X/X green Phyrexian Horror creature token, where X is Nissa's loyalty." — created no token at all.

After card-name normalization the effect quantity is ~'s loyalty. The self-possessive quantity leaf in parse_quantity_ref had arms for ~'s power and ~'s toughness but none for loyalty, so parse_cda_quantity returned None, try_parse_token bailed (its CR 107.3c guard against a 0/0 dead node), and the whole [+1] degraded to Effect::Unimplemented.

Fix: add parse_self_loyalty_ref, composed from the shared parse_self_possessive prefix + tag(" loyalty"), mapping every self-referent phrasing (its loyalty / ~'s loyalty / this card's loyalty) to the existing QuantityRef::CountersOn { scope: Source, counter_type: Some(Loyalty) }. Per CR 306.5c, a planeswalker's loyalty equals the number of loyalty counters on it. The runtime already resolves counter-on-source quantities and already resolves token P/T against the ability source — no new engine infra. Class-level, not a one-off.

Gate A

./scripts/check-parser-combinators.sh — clean. parse_self_loyalty_ref is pure nom (parse_self_possessive + tag); registered by nesting the power/toughness/loyalty arms into a sub-alt (to stay within nom 8.0's 21-item tuple arity). Three-dot diff of crates/engine/src/parser/** grepped for forbidden methods: empty.

Anchored on

  • crates/engine/src/parser/oracle_nom/quantity.rs — new parse_self_loyalty_ref + its registration in parse_quantity_ref.
  • QuantityRef::CountersOn { scope: Source, counter_type: Loyalty } — the existing referent it maps to; resolve_counters_on_scope already reads loyalty counters on the source, and build_token_attrs_from_effect → resolve_pt_value already sizes the token from it.

Verification

  • cargo fmt --all — clean.
  • Parser combinator gate — empty.
  • cargo clippy -p engine --tests -- -D warnings — clean.
  • Regression test issue_6012_nissa_plus_one_creates_horror_token_sized_to_loyalty in crates/engine/tests/integration/nissa_ascended_animist_loyalty_token_6012.rs (registered in tests/integration/main.rs) — builds Nissa with 5 loyalty, resolves [+1], asserts a Horror token with power/toughness = 5, and guards the body is not Unimplemented. Fails before, passes after. Plus a building-block parser unit test (~'s loyalty/its loyalty/this card's loyaltyCountersOn{Source, Loyalty}).
  • CR verified against docs/MagicCompRules.txt: 306.5c (loyalty = loyalty counters).
  • Blast radius: rebuilt oracle-gen, regenerated card-data.json, diffed the full database — exactly 2 cards changed, both correct improvements: Nissa, Ascended Animist ([+1]Effect::Token, P/T = source loyalty, was Unimplemented); and Nahiri, the Unforgiving [0] — "exile … with mana value less than Nahiri's loyalty" now keeps its previously-dropped CMC constraint. No collateral.

Scope Expansion

None.

…tity

Closes phase-rs#6012.

Nissa, Ascended Animist — '[+1]: Create an X/X green Phyrexian Horror
creature token, where X is Nissa's loyalty.' — created no token. After
card-name normalization the effect quantity is '~'s loyalty', but the
self-possessive quantity leaf had arms only for power and toughness, so
parse_cda_quantity returned None, the token clause failed to lower, and the
+1 degraded to Effect::Unimplemented.

Add parse_self_loyalty_ref, composed from the shared parse_self_possessive
prefix + tag(' loyalty'), mapping every self-referent phrasing to the
existing QuantityRef::CountersOn { scope: Source, counter_type: Loyalty }.
CR 306.5c: a planeswalker's loyalty equals the number of loyalty counters on
it. Runtime already resolves counter-on-source quantities and token P/T
against the ability source — no new engine infra.

Whole-DB regen: 2 cards fixed. Nissa +1 now lowers to Effect::Token
(Phyrexian Horror, P/T = source loyalty) instead of Unimplemented; and
Nahiri, the Unforgiving [0] ('exile ... with mana value less than Nahiri's
loyalty') now keeps its previously-dropped CMC constraint. Runtime
regression drives Nissa's +1 at 5 loyalty and asserts a 5/5 Horror token.
@dhgoal
dhgoal requested a review from matthewevans as a code owner July 16, 2026 19:52
@superagent-security superagent-security Bot added the contributor:flagged Contributor flagged for review by trust analysis. label Jul 16, 2026
@superagent-security

Copy link
Copy Markdown

🚨 Contributor flagged. Click here for more info: Superagent Dashboard

@matthewevans matthewevans self-assigned this Jul 16, 2026

@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 adds support for parsing self-possessive loyalty references (e.g., '~'s loyalty') in Oracle text, resolving an issue where Nissa, Ascended Animist's +1 ability failed to create a token. The changes include updates to the quantity parser, a new unit test, and an integration test. The reviewer suggests refactoring the existing self-possessive characteristic parsers into a single parameterized function to improve maintainability and adhere to the repository's 'parameterize, don't proliferate' style rule.

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.

Comment on lines +2460 to +2480
/// Parse "its loyalty" / "~'s loyalty" / "this creature's loyalty" /
/// "this card's loyalty" — the loyalty of the ability's source planeswalker.
///
/// CR 306.5c: The loyalty of a planeswalker on the battlefield is equal to the
/// number of loyalty counters on it, so a source-scoped loyalty reference is the
/// count of `Loyalty` counters on the source (Nissa, Ascended Animist:
/// "Create an X/X green Phyrexian Horror creature token, where X is ~'s
/// loyalty"). Composed from the shared `parse_self_possessive` prefix so every
/// self-referent phrasing routes to `CountersOn { Source, Loyalty }`, mirroring
/// `parse_self_power_ref`.
fn parse_self_loyalty_ref(input: &str) -> OracleResult<'_, QuantityRef> {
let (rest, _) = parse_self_possessive(input)?;
let (rest, _) = tag(" loyalty").parse(rest)?;
Ok((
rest,
QuantityRef::CountersOn {
scope: crate::types::ability::ObjectScope::Source,
counter_type: Some(CounterType::Loyalty),
},
))
}

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.

medium

With the addition of parse_self_loyalty_ref, there are now three similar functions for parsing self-possessive characteristics (..._power_ref, ..._toughness_ref, ..._loyalty_ref). This introduces a 'sibling-cluster smell' as per rule R3 of the repository style guide, which advises to 'Parameterize, don't proliferate'.

Before extending this pattern, consider refactoring these three functions into a single parameterized one. This would reduce code duplication and make it easier to add other self-possessive characteristics in the future.

For example, you could implement a single parser:
fn parse_self_characteristic_ref(input: &str) -> OracleResult<', QuantityRef> {
let (rest, ) = parse_self_possessive(input)?;
let (rest, characteristic) = alt((
map(tag(" power"), |
| QuantityRef::Power { scope: ObjectScope::Source }),
map(tag(" toughness"), |
| QuantityRef::Toughness { scope: ObjectScope::Source }),
map(tag(" loyalty"), |_| QuantityRef::CountersOn { scope: ObjectScope::Source, counter_type: Some(CounterType::Loyalty) }),
)).parse(rest)?;
Ok((rest, characteristic))
}
This new function could then replace the alt((...)) block at lines 752-756 with a single call, which would also remove the need for nesting to avoid nom's arity limit.

References
  1. Avoid verbatim string equality for parsing Oracle phrases. Instead, decompose compound phrases into modular, reusable parsers and compose them using idiomatic combinator aggregates to prevent combinatorial explosion and improve maintainability.

Co-authored-by: dhgoal <153369624+dhgoal@users.noreply.github.com>
@matthewevans matthewevans added the bug Bug fix label Jul 16, 2026
@matthewevans matthewevans removed their assignment Jul 16, 2026
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 2 card(s), 3 signature(s) (baseline: main 5e27789c191b)

🟢 Added (1 signature)

  • 1 card · ➕ ability/Token · added: Token (kind=activated, timing=sorcery speed, token=+loyalty counters on self/+loyalty counters on self Green Phyrexian Horror (Creature Phyrexian Horror))
    • Affected (first 3): Nissa, Ascended Animist

🔴 Removed (1 signature)

  • 1 card · ➖ ability/create · removed: create (kind=activated, timing=sorcery speed)
    • Affected (first 3): Nissa, Ascended Animist

🟡 Modified fields (1 signature)

  • 1 card · 🔄 ability/ChangeZone · changed field target: creature or Equipmentmv loyalty counters on self+-1- in graveyard you control creature or mv loyalty counters on self+-1- in graveyard you c…
    • Affected (first 3): Nahiri, the Unforgiving

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

@matthewevans matthewevans self-assigned this Jul 16, 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 self-characteristic nom parser now maps source loyalty to its existing typed CountersOn { Source, Loyalty } representation; the Nissa activation regression exercises the post-cost runtime path.

@matthewevans matthewevans added the quality For high-quality minimal to no-churn PRs label Jul 16, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 16, 2026
@matthewevans matthewevans removed their assignment Jul 16, 2026
Merged via the queue into phase-rs:main with commit 145906c Jul 16, 2026
12 of 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 contributor:flagged Contributor flagged for review by trust analysis. quality For high-quality minimal to no-churn PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nissa, Ascended Animist — when using her +1 ability, it does not create the phyrexian Horror creature

2 participants