Skip to content

feat(parser): +X/+Y dynamic pump with per-axis quantities (Aspect of Wolf)#5743

Merged
matthewevans merged 4 commits into
phase-rs:mainfrom
nickmopen:feat/dynamic-pt-distinct-xy-axes
Jul 13, 2026
Merged

feat(parser): +X/+Y dynamic pump with per-axis quantities (Aspect of Wolf)#5743
matthewevans merged 4 commits into
phase-rs:mainfrom
nickmopen:feat/dynamic-pt-distinct-xy-axes

Conversation

@nickmopen

Copy link
Copy Markdown
Contributor

Summary

A dynamic P/T pump whose two axes bind to different quantities — gets +X/+Y, where X is <A> and Y is <B> — dropped entirely. Aspect of Wolf now grants +X/+Y where X = half the number of Forests you control, rounded down (power) and Y = the same, rounded up (toughness).

Implementation method

Hand-authored, grammar-only — no new engine variant. Both quantities (QuantityExpr::DivideRounded over ObjectCount) already exist and are runtime-evaluated; the parser just couldn't reach the second axis.

Three seams:

  • grammar.rsparse_variable_pt_pattern accepts y as a dynamic axis (was x/digit only).
  • anthem.rsparse_dynamic_pt_in_text splits the binding clause on and y is (case-insensitively) and resolves each half via parse_cda_quantity, assigning X→power, Y→toughness. Single-quantity clauses (+X/+X, +X/+0, …) are unchanged.
  • lower.rsstructurally_bound_where_x_clause keeps a <X qty>, and Y is <Y qty> continuation (a binding continuation, not a new instruction) only when both halves independently parse as where-X quantities, so a genuine …, and <verb> next-instruction still bounds off.

CR references

CR 613.4c (Layer 7c additive P/T) + CR 107.1a (rounding). Touched, not invented.

Files changed

  • parser/oracle_static/grammar.rs, parser/oracle_static/anthem.rs (+ helper split_x_and_y_where_clause)
  • parser/oracle_effect/lower.rs (where-X bounding)
  • parser/oracle_static/mod.rs (prelude: RoundingMode), tests.rs

Verification (local, committed head f900c7dfdd42690dd03d4bd52b5c0c703b197145)

  • cargo test -p engine --lib dynamic_pt_pump_binds_x_and_y_axes_to_distinct_quantities — ok (power = DivideRounded/2/Down, toughness = DivideRounded/2/Up)
  • cargo test -p engine --lib parser::oracle — ok (7904 passed, 0 failed) — full sweep, since the changed functions are core to every dynamic pump and where-X card
  • cargo fmt -p engine -- --check, cargo clippy -p engine --lib (-D warnings) — clean
  • scripts/check-parser-combinators.sh <base>, scripts/check-engine-authorities.sh <base> — pass

Transparency: did not run scripts/ai-gate.sh (Gate A) — fresh --release build, local disk tight; not fabricating a PASS line. CI covers this head.

Anchored on

  • crates/engine/src/parser/oracle_static/anthem.rs — the existing single-quantity where X resolution in parse_dynamic_pt_in_text this generalizes.
  • crates/engine/src/parser/oracle_effect/lower.rs — the existing comma-bounded structurally_bound_where_x_clause (the and Y is case is added ahead of it).

Claimed parse impact

  • Aspect of Wolf+X/+Y per-axis pump now parses (was dropped). (Phyrexian Ingester shares this +X/+Y shape but additionally needs an exiled-card toughness quantity — a separate follow-up.)

@nickmopen
nickmopen requested a review from matthewevans as a code owner July 13, 2026 16:15
@matthewevans matthewevans added the enhancement New feature or request label Jul 13, 2026
@matthewevans matthewevans self-assigned this Jul 13, 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.

Request changes — the claimed supported-card behavior still lacks a discriminating runtime witness.

🔴 Blocker

  • crates/engine/src/parser/oracle_static/tests.rs:28788-28827 verifies only the parser output. It never installs Aspect of Wolf on an enchanted creature, supplies an odd Forest count, or observes the layer result. A regression in static-definition attachment, recipient rebinding, or dynamic quantity resolution would still pass while a supported card gets the wrong P/T. Add an integration test through the game pipeline that shows three Forests yield +1/+2, and that fails if the per-axis binding is reverted.

🟡 Non-blocking

  • The parser test correctly distinguishes the down/up QuantityExprs; keep it as the narrow grammar guard alongside the runtime test.

✅ Clean

  • The production parser reaches the existing dynamic-P/T authority and uses the existing rounded quantity expression rather than adding new engine surface. The fetched Oracle text matches the comma-separated X/Y form handled by this diff.

Recommendation: request changes — add the end-to-end odd-count runtime regression before marking Aspect of Wolf supported.

@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 introduces support for parsing and handling compound "+X/+Y" pump binding clauses (such as in Aspect of Wolf) where the X and Y axes bind to different quantities. It updates the parser to split these clauses on "and Y is", resolves each axis independently, and allows 'y' as a dynamic variable axis in the grammar. Feedback focuses on ensuring strict adherence to the project's architectural rules (R1), specifically pointing out that the manual string splitting and trimming operations bypass the nom parser framework. Additionally, a minor documentation inconsistency regarding the casing of the input string was noted.

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 +7305 to +7311
if let Some((x_part, y_part)) = clause.split_around(", and y is ") {
if parse_where_x_quantity_expression(x_part.original).is_some()
&& parse_where_x_quantity_expression(y_part.original).is_some()
{
return clause;
}
}

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.

high

This new logic uses clause.split_around(", and y is ") for parsing dispatch, which appears to violate style guide rule R1. R1 mandates the use of nom combinators for all new parser dispatch logic. Using string-splitting methods for dispatching logic is explicitly discouraged.

A more idiomatic approach would be to use a nom parser here. For example, you could attempt to parse the clause with nom::sequence::separated_pair and nom::bytes::complete::tag_no_case(", and y is "). If it succeeds, you know you have the two-part quantity expression. This would align better with the architectural rules of the project.

References
  1. R1. Nom combinators on the first pass — no exceptions. Every new parser dispatch under crates/engine/src/parser/ must use nom 8.0 combinators. Findings include any new .contains("..."), .starts_with("..."), .ends_with("..."), .find("..."), or .split_once("...") used for parsing dispatch in non-test parser code. (link)
  2. Avoid verbatim string equality for parsing Oracle phrases as it bypasses the robust nom-based parser and creates fragile matches. Instead, decompose compound phrases into modular, reusable parsers for constituent parts and compose them using idiomatic combinator aggregates to prevent combinatorial explosion and improve maintainability.

Comment on lines +1169 to +1173
let (_, (x_expr, y_expr)) = nom_primitives::split_once_on(&lower, " and y is ").ok()?;
Some((
x_expr.trim().trim_end_matches(',').to_string(),
y_expr.trim().trim_end_matches('.').trim().to_string(),
))

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.

high

The use of .trim().trim_end_matches(',') and .trim().trim_end_matches('.').trim() in split_x_and_y_where_clause violates style guide rule R1. This rule mandates using nom combinators for parsing logic, and manual string manipulation for cleaning up parser inputs is an anti-pattern in this codebase.

Instead of manually trimming, this logic should be incorporated into the consuming parsers using nom combinators. For example, parse_cda_quantity could be wrapped to handle optional leading/trailing whitespace and punctuation. If modifying parse_cda_quantity is not feasible, a local nom parser could be defined within split_x_and_y_where_clause to handle this cleaning idiomatically.

References
  1. R1. Nom combinators on the first pass — no exceptions. Every new parser dispatch under crates/engine/src/parser/ must use nom 8.0 combinators. Findings include any new .contains("..."), .starts_with("..."), .ends_with("..."), .find("..."), or .split_once("...") used for parsing dispatch in non-test parser code. (link)
  2. Avoid redundant string prefix checks or manual trimming before calling parsing functions that already perform leading character validation and whitespace trimming, and avoid verbatim string equality or manual string manipulation for parsing Oracle phrases by using robust nom-based parsers.

Comment on lines +1160 to +1161
/// rounded up"). Returns `None` for the common single-quantity clause. `wx` is
/// already lowercased, so the " and y is " boundary matches the printed

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

The comment on line 1161 states that "wx is already lowercased", but the implementation on line 1168 does wx.to_lowercase(). The comment on line 1165 correctly states that wx arrives in original case. This inconsistency can be confusing. Please update the comment to reflect the implementation.

Suggested change
/// rounded up"). Returns `None` for the common single-quantity clause. `wx` is
/// already lowercased, so the " and y is " boundary matches the printed
/// rounded up"). Returns `None` for the common single-quantity clause. The
/// " and y is " boundary is matched case-insensitively. The trailing comma left

@matthewevans matthewevans removed their assignment Jul 13, 2026
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 1 card(s), 2 signature(s) (baseline: main 6d4184492403)

1 card(s) · static/Continuous · added: Continuous (affects=enchanted by self creature, mods=add dynamic power, add dynamic toughness)

Examples: Aspect of Wolf

1 card(s) · ability/static_structure · removed: static_structure

Examples: Aspect of Wolf

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

@nickmopen

Copy link
Copy Markdown
Contributor Author

Thanks both — addressed.

@matthewevans (blocker — runtime witness): added tests/integration/aspect_of_wolf_per_axis_xy.rs. It installs Aspect of Wolf on a 2/2, gives the controller 3 Forests (odd, so the axes diverge), attaches, runs evaluate_layers + derive_display_state, and asserts the host resolves to 3/4 — X = half rounded down = 1 (power), Y = half rounded up = 2 (toughness). Reverting the per-axis binding makes it 3/3 or 4/4 and the test fails. The narrow parser test stays as the grammar guard, per your non-blocking note.

@gemini (R1):

  • lower.rs: replaced clause.split_around(", and y is ") with the nom nom_primitives::split_once_on combinator (parse_where_x_quantity_expression lowercases internally, so the lower halves feed it directly).
  • anthem.rs: split on ", and y is " so the joining comma is consumed by the separator, and the sentence period is already stripped upstream by strip_trailing_where_x — so both trim_end_matches(',' / '.') are gone; only a plain whitespace trim() remains.
  • Fixed the stale "already lowercased" doc line.

Local: unit + new integration test pass; parser::oracle 7905 passed, 0 failed; fmt / clippy -D warnings / parser-combinator + engine-authorities gates clean.

nickmopen added a commit to nickmopen/phase that referenced this pull request Jul 13, 2026
…m cleanup

- Blocker (matthewevans): add tests/integration/aspect_of_wolf_per_axis_xy.rs
  — installs Aspect of Wolf on a 2/2, controls 3 Forests, and asserts the layer
  pipeline yields 3/4 (X = half rounded down = 1 power, Y = half rounded up = 2
  toughness). Fails if the per-axis binding is reverted.
- R1 (gemini): replace the lower.rs split_around with the nom split_once_on
  combinator; split the anthem clause on ', and y is ' so the joining comma is
  consumed and the manual trim_end_matches(',' / '.') go away (period is already
  stripped upstream). Fix the stale 'already lowercased' doc line.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Thanks for the detailed breakdown and for adding the integration test. The approach of splitting the binding clause and resolving each axis independently is a robust way to handle the Aspect of Wolf logic without introducing new engine variants. Everything looks clean and well-verified.

@matthewevans matthewevans self-assigned this Jul 13, 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.

Request changes — the new per-axis grammar incorrectly treats unbound -X/-Y text as a cost-X pump.

🔴 Blocker

  • parse_variable_pt_pattern now accepts y as a dynamic axis unconditionally, while parse_dynamic_pt_in_text still defaults every missing binding to QuantityRef::CostXPaid. The current parse-diff confirms the resulting regression: Snowblind gained a static Continuous parse. Its Oracle text is Enchanted creature gets -X/-Y. If ... X is ... Y is ...; it has no {X} cost and its X/Y values are defined by later conditional sentences. The new static parser therefore emits -CostXPaid/-CostXPaid, which resolves to 0 for the Aura rather than its rules-defined reduction. Keep Y distinct from X and accept the second dynamic axis only when the same where X is <A>, and Y is <B> binding was structurally parsed; otherwise preserve the existing unsupported result. Add a regression guard for Snowblind (no synthetic cost-X static), then regenerate/inspect the parse diff.

🟡 Non-blocking

  • The current parse-diff also adds Bioplasm, although the PR claims only Aspect of Wolf. Once the blocker is fixed, either include Bioplasm in the stated scope with an appropriate behavioral witness or narrow the change so the documented parse impact matches the actual card set.

✅ Clean

  • The new Aspect integration test is a real layer-pipeline witness: three Forests produce 3/4, so it distinguishes the down/up axes from a shared quantity. The nom_primitives::split_once_on replacement addresses Gemini's stale R1 review finding.

Recommendation: request changes — guard Y-axis parsing on the structured paired binding and prove Snowblind remains honest before re-review.

@matthewevans matthewevans removed their assignment Jul 13, 2026
…Wolf)

A dynamic P/T pump whose two axes bind to DIFFERENT quantities —
"gets +X/+Y, where X is <A> and Y is <B>" — dropped: parse_variable_pt_pattern
rejected the 'y' axis, and parse_dynamic_pt_in_text applied one quantity to
both axes; the where-clause bounding also truncated the 'and Y is <B>' half.

- grammar.rs: parse_variable_pt_pattern accepts 'y' as a dynamic axis.
- anthem.rs: parse_dynamic_pt_in_text splits the binding clause on ' and y is '
  and resolves each half via parse_cda_quantity, assigning X to power and Y to
  toughness (single-quantity clauses unchanged).
- lower.rs: structurally_bound_where_x_clause keeps a '<X qty>, and Y is <Y qty>'
  continuation (a binding continuation, not a new instruction) when both halves
  parse as where-X quantities.

Aspect of Wolf now grants +X/+Y where X = half the number of Forests you
control rounded down (power) and Y = the same rounded up (toughness). No new
engine variant; both quantities (DivideRounded over ObjectCount) already exist.
…m cleanup

- Blocker (matthewevans): add tests/integration/aspect_of_wolf_per_axis_xy.rs
  — installs Aspect of Wolf on a 2/2, controls 3 Forests, and asserts the layer
  pipeline yields 3/4 (X = half rounded down = 1 power, Y = half rounded up = 2
  toughness). Fails if the per-axis binding is reverted.
- R1 (gemini): replace the lower.rs split_around with the nom split_once_on
  combinator; split the anthem clause on ', and y is ' so the joining comma is
  consumed and the manual trim_end_matches(',' / '.') go away (period is already
  stripped upstream). Fix the stale 'already lowercased' doc line.
…ng (Snowblind)

matthewevans [HIGH]: accepting 'y' unconditionally regressed Snowblind
("gets -X/-Y", X/Y defined by later sentences, no {X} cost) into a bogus
-CostXPaid/-CostXPaid static.

parse_variable_pt_pattern now returns a typed PtAxisMag { Fixed | VarX |
VarY } per axis. parse_dynamic_pt_in_text accepts a distinct '+X/+Y'
(VarX on power, VarY on toughness) ONLY when the paired
'where X is <A>, and Y is <B>' binding is structurally present; otherwise
the pattern is left unsupported (no synthesized cost-X). Same-letter and
fixed axes are unchanged.

- Snowblind '-X/-Y' now drops (regression guard added).
- Bioplasm ('...power and Y is its toughness', no joining comma) no
  longer matches the ', and y is ' split, so it also stays unsupported —
  the parse impact narrows back to Aspect of Wolf only.
@nickmopen

Copy link
Copy Markdown
Contributor Author

Good catch — fixed the regression.

Blocker (Snowblind synthetic cost-X): parse_variable_pt_pattern now returns a typed PtAxisMag { Fixed(i32) | VarX | VarY } per axis instead of a bare Option<i32>. parse_dynamic_pt_in_text accepts a distinct +X/+Y (VarX on power, VarY on toughness) only when the structured where X is <A>, and Y is <B> paired binding is present; otherwise the pattern is left unsupported — no cost-X synthesis. So:

  • Snowblind gets -X/-Y (X/Y defined by later conditional sentences, no {X} cost) now drops — added a regression guard distinct_xy_pump_without_paired_binding_is_unsupported that fails if it ever re-synthesizes a static.
  • Same-letter (+X/+X) and mixed (+X/+1) axes are unchanged (29 dynamic_pt tests green).

Non-blocking (Bioplasm): its binding is "…the exiled creature card's power **and** Y is its toughness"no joining comma, so it doesn't match the ", and y is " split and stays unsupported too. The parse impact is now exactly Aspect of Wolf as claimed. (Bioplasm additionally needs an exiled-card toughness quantity, which doesn't exist yet — a separate add-engine-variant follow-up.)

Local: full parser::oracle 7906 passed, 0 failed; the Aspect layer-pipeline integration test + the new Snowblind guard pass; fmt / parser-combinator / engine-authorities gates clean. Please re-check the regenerated parse-diff — it should now be Aspect of Wolf only.

@nickmopen
nickmopen force-pushed the feat/dynamic-pt-distinct-xy-axes branch from f81bcda to ecbe3ea Compare July 13, 2026 17:46
@matthewevans matthewevans self-assigned this Jul 13, 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 per-axis parser is now constrained to the supported paired +X/+Y form and has a discriminating layer-pipeline regression.

🔴 Blocker

  • None.

🟡 Non-blocking

  • None.

✅ Clean

  • Aspect of Wolf’s odd three-Forest runtime test distinguishes the rounded-down power axis from the rounded-up toughness axis through attachment and layer evaluation.
  • The structured paired binding is the only accepted Y form; Snowblind and the unsupported +Y/+X/+Y/+Y shapes cannot synthesize cost-X semantics.
  • The fresh parse-diff contains only Aspect of Wolf, matching the claimed one-card scope.

Recommendation: approve and enqueue.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants