Skip to content

test(engine): runtime regression for Riptide Gearhulk optional per-opponent targets (#5994)#6034

Merged
matthewevans merged 1 commit into
phase-rs:mainfrom
galuis116:fix/per-opponent-fanout-up-to-min
Jul 24, 2026
Merged

test(engine): runtime regression for Riptide Gearhulk optional per-opponent targets (#5994)#6034
matthewevans merged 1 commit into
phase-rs:mainfrom
galuis116:fix/per-opponent-fanout-up-to-min

Conversation

@galuis116

@galuis116 galuis116 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What this is now

The parser fix for #5994 landed independently via #6565 (merged), which rewrote per_opponent_target_fanout_min with the verb-general scan_at_word_boundaries scan. #5994 is closed. This PR is repurposed to just the runtime target-selection regression � coverage main was still missing � rebased onto current main.

Tests

crates/engine/tests/integration/riptide_gearhulk_5994.rs drives Riptide Gearhulk's real ETB through the cast pipeline into WaitingFor::TriggerTargetSelection with two opponents, each controlling a nonland permanent:

  • riptide_declining_all_per_opponent_targets_does_not_fizzle â�� declines every optional per-opponent target; asserts the ETB resolves back to Priority (both permanents untouched), i.e. it does not stall or fizzle.
  • riptide_selects_one_opponent_target_and_declines_the_other â�� chooses one opponent's permanent (moved to its owner's library third from the top) while declining the other's optional slot (untouched); asserts the ability resolves.

Both are non-vacuous: they pass on current main and fail if per_opponent_target_fanout_min is forced back to a mandatory min: 1. On that revert Riptide lowers to a non-targeted EffectZoneChoice that stalls short of Priority, and the chosen permanent never moves � so the final_waiting_for() and zone assertions each flip.

cargo fmt, clippy -p engine --tests, and the new integration tests are green.

Refs #5994

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue with Riptide Gearhulk’s enter-the-battlefield ability when optional targets are declined.
    • Ensured the ability resolves correctly when no opponents’ permanents are selected.
    • Ensured selected permanents are moved as intended while declined permanents remain on the battlefield.
  • Tests

    • Added integration coverage for all-declined and mixed target-selection scenarios.

@galuis116
galuis116 requested a review from matthewevans as a code owner July 16, 2026 17:17
@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 updates the per_opponent_target_fanout_min function to correctly handle optional target fanouts by scanning for prefixes at word boundaries, ensuring that abilities do not fizzle when a target is declined. The reviewer correctly identified that the implementation of a manual parsing loop violates the project's architectural rule requiring the use of nom combinators for parser dispatch and provided a refactored, idiomatic solution.

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 3448 to 3461
pub(super) fn per_opponent_target_fanout_min(text: &str) -> usize {
let mut remaining = text;
while !remaining.is_empty() {
if let (_, Some(spec)) = strip_optional_target_prefix(remaining) {
return if spec.min_is_fixed_zero() { 0 } else { 1 };
}
// allow-noncombinator: structural word-boundary advance on a runtime ' '
// separator (the documented scan idiom), not dispatch against a literal.
remaining = remaining
.find(' ')
.map_or("", |idx| remaining[idx + 1..].trim_start());
}
1
}

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 manual parsing loop should be refactored to use nom combinators. Per the repository rules, we avoid manual string slicing and verbatim matching for Oracle phrases to ensure maintainability and robustness. Please use the existing nom-based primitives instead.

Suggested change
pub(super) fn per_opponent_target_fanout_min(text: &str) -> usize {
let mut remaining = text;
while !remaining.is_empty() {
if let (_, Some(spec)) = strip_optional_target_prefix(remaining) {
return if spec.min_is_fixed_zero() { 0 } else { 1 };
}
// allow-noncombinator: structural word-boundary advance on a runtime ' '
// separator (the documented scan idiom), not dispatch against a literal.
remaining = remaining
.find(' ')
.map_or("", |idx| remaining[idx + 1..].trim_start());
}
1
}
pub(super) fn per_opponent_target_fanout_min(text: &str) -> usize {
let parser = |i: &str| -> nom::IResult<&str, MultiTargetSpec, OracleError<'_>> {
let (rem, spec_opt) = strip_optional_target_prefix(i);
if let Some(spec) = spec_opt {
Ok((rem, spec))
} else {
Err(nom::Err::Error(OracleError::new(
i,
nom::error::ErrorKind::Tag,
)))
}
};
if let Some((spec, _)) = nom_primitives::scan_at_word_boundaries(text, parser) {
if spec.min_is_fixed_zero() { 0 } else { 1 }
} else {
1
}
}
References
  1. Avoid verbatim string equality for parsing Oracle phrases; use modular, reusable nom-based parsers and combinators to improve maintainability.

@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.

Changes requested — two correctness/verification blockers remain on 3ef0bf405d0d0f85c8990d875394b1101dcb1b46.

🟡 Medium

  1. Correct the CR citation. Evidence: crates/engine/src/parser/oracle_effect/lower.rs:3436 and crates/engine/src/parser/oracle_effect/tests.rs:46610 cite CR 115.10 for optional target count. The local Comprehensive Rules source at docs/MagicCompRules.txt:886-890 says 115.10 only distinguishes objects affected without targeting; it does not establish that up to one target permits zero choices. Remove 115.10 (retaining the verified relevant CR 601.2c is sufficient) or replace it with a verified rule that supports the claim.

  2. Add a production target-selection/resolution regression. Evidence: crates/engine/src/parser/oracle_effect/tests.rs:46688 calls only parse_oracle_text and asserts MultiTargetSpec.min; it does not exercise the reported behavior that declining a per-opponent target must not fizzle Riptide Gearhulk's ETB. Drive the real trigger pipeline through WaitingFor::TriggerTargetSelection, including the per-opponent player/object slots: prove one opponent can be selected while another object's optional slot is declined, and prove all optional object slots can be declined while the ability resolves. The test must fail if the min lowering reverts to 1.

✅ Clean

The verb-general optional-prefix scan is at the shared per-opponent fanout lowering seam and follows the repository's documented word-boundary scan idiom; the direct parser coverage spans put, exile, and gain control.

Please address both findings, then rerun the current-head parser/CI evidence. I did not apply a maintainer fixup: the required runtime scenario needs a verified player/object/None selection sequence, and the PR explicitly identifies related fanout-resolution behavior as out of scope.

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

Copy link
Copy Markdown

Parse changes introduced by this PR · 4 card(s), 4 signature(s) (baseline: main e085a8d5fa08)

1 card(s) · ability/DealDamage · field targets: 1-# of each opponent0-# of each opponent

Examples: Tri-Sentinel, Act of Vengeance

1 card(s) · ability/PutAtLibraryPosition · field targets: 1-# of each opponent0-# of each opponent

Examples: Riptide Gearhulk

1 card(s) · ability/PutCounter · field targets: 1-# of each opponent0-# of each opponent

Examples: Shy Town

1 card(s) · ability/base power 0, base toughness 4, grant Defender, add type artifact, add type cre… · field targets: 1-# of each opponent0-# of each opponent

Examples: Welcome to . . .

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

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

Copy link
Copy Markdown
Member

This PR remains blocked by the requested changes on 3ef0bf405d0d0f85c8990d875394b1101dcb1b46 (the CR citation and the discriminating declined-target runtime-path coverage). Please address them or comment with active progress. If the requested changes are not addressed within 7 days, this PR will be automatically closed.

@galuis116
galuis116 force-pushed the fix/per-opponent-fanout-up-to-min branch from 3ef0bf4 to 5e5bad5 Compare July 24, 2026 19:24
galuis116 added a commit to galuis116/phase that referenced this pull request Jul 24, 2026
…n + fix CR citation

Address the two review blockers on phase-rs#6034.

1. CR citation. `per_opponent_target_fanout_min`'s doc and the Riptide parser test
   cited CR 115.10, which governs objects/players AFFECTED without being targeted
   -- it does not establish that "up to one target" permits zero choices. Replace
   it with the verified CR 601.2c: a spell/ability "with a variable number of
   targets" lets the player announce how many targets they will choose, which is
   the rule that makes "up to one target" optional. (Verified against
   docs/MagicCompRules.txt:2461.)

2. Runtime regression. Add crates/engine/tests/integration/riptide_gearhulk_5994.rs,
   driving Riptide's real ETB through the cast pipeline into
   WaitingFor::TriggerTargetSelection with two opponents:
   - `riptide_declining_all_per_opponent_targets_does_not_fizzle` declines every
     per-opponent target and asserts the ETB resolves back to Priority (not a
     stall) with both permanents untouched.
   - `riptide_selects_one_opponent_target_and_declines_the_other` chooses one
     opponent's permanent (moved to its owner's library) while declining the
     other's optional slot (untouched), and asserts the ability resolves.

   Both are non-vacuous: verified they PASS with the fix and FAIL when
   `per_opponent_target_fanout_min` is forced back to a mandatory min of 1 -- on
   that revert Riptide lowers to a non-targeted `EffectZoneChoice` that stalls
   mid-resolution (never reaching Priority) and the chosen permanent never moves,
   so each assertion flips.

Refs phase-rs#5994
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Riptide Gearhulk integration coverage for optional per-opponent nonland-permanent targets, including cases where all targets are declined and where only one opponent’s permanent is moved to its owner’s library.

Changes

Riptide Gearhulk regression coverage

Layer / File(s) Summary
Riptide test setup and harness wiring
crates/engine/tests/integration/main.rs, crates/engine/tests/integration/riptide_gearhulk_5994.rs
Registers the new integration module and defines the card text, player fixture, and mana-pool helper used by the tests.
Optional target resolution scenarios
crates/engine/tests/integration/riptide_gearhulk_5994.rs
Tests declining all opponent targets and selecting only one opponent’s permanent, including battlefield and library placement assertions.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • phase-rs/phase#6565: Fixes optional-target fanout parsing logic covered by these Riptide Gearhulk regression tests.

Suggested labels: bug

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: runtime regression tests for Riptide Gearhulk’s optional per-opponent targets.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

…opponent targets (phase-rs#5994)

The parser fix for phase-rs#5994 landed via phase-rs#6565 (the verb-general
`per_opponent_target_fanout_min` scan). This adds the runtime target-selection
regression that main was still missing, driving Riptide's real ETB through the
cast pipeline into `WaitingFor::TriggerTargetSelection` with two opponents:

- `riptide_declining_all_per_opponent_targets_does_not_fizzle` declines every
  optional per-opponent target and asserts the ETB resolves back to `Priority`
  (both permanents untouched) rather than stalling mid-resolution.
- `riptide_selects_one_opponent_target_and_declines_the_other` chooses one
  opponent's permanent (moved to its owner's library) while declining the other's
  optional slot (untouched), and asserts the ability resolves.

Both are non-vacuous: they pass on current main and fail if
`per_opponent_target_fanout_min` is forced back to a mandatory min of 1 — on that
revert Riptide lowers to a non-targeted `EffectZoneChoice` that stalls (never
reaching `Priority`) and the chosen permanent never moves, so the
`final_waiting_for()` and zone assertions each flip.

Refs phase-rs#5994
@galuis116
galuis116 force-pushed the fix/per-opponent-fanout-up-to-min branch from 5e5bad5 to cf8c27a Compare July 24, 2026 20:01
@galuis116 galuis116 changed the title fix(parser): honor "up to" in a per-opponent target fanout for any verb (Riptide Gearhulk) test(engine): runtime regression for Riptide Gearhulk optional per-opponent targets (#5994) Jul 24, 2026
@galuis116

Copy link
Copy Markdown
Contributor Author

Update: while rebasing to address your two blockers, I found that #6565 (merged) already landed the parser fix for #5994 � it rewrote per_opponent_target_fanout_min with the verb-general scan_at_word_boundaries scan, which supersedes this PR's parser change (and independently fixed the "that player controls" scope facet I'd deferred). #5994 is closed.

Rather than push a now-redundant duplicate, I've repurposed this PR to just the runtime regression you asked for (cf8c27a43), rebased onto current main. Main didn't have a Riptide target-selection test, so this is additive coverage:

  • riptide_declining_all_per_opponent_targets_does_not_fizzle â�� drives Riptide's real ETB through the cast pipeline into WaitingFor::TriggerTargetSelection with two opponents, declines every optional per-opponent target, and asserts the ability resolves back to Priority.
  • riptide_selects_one_opponent_target_and_declines_the_other â�� selects one opponent's permanent (moved to its owner's library) while declining the other's optional slot, and asserts resolution.

Both are non-vacuous: they pass on current main and fail if per_opponent_target_fanout_min is forced back to a mandatory min: 1 (Riptide then lowers to a non-targeted EffectZoneChoice that stalls short of Priority, and the chosen permanent never moves � so final_waiting_for() and the zone assertions each flip).

The CR-citation blocker is moot now that the parser code is main's, but I applied the same correction to the test doc (CR 601.2c � the verified "variable number of targets" rule � not CR 115.10).

Happy to close this instead if you'd rather not carry a test-only PR; the coverage seemed worth keeping given you'd asked for the runtime scenario.

@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 on cf8c27a: the test-only head exercises the real cast/trigger pipeline for all-declined and mixed per-opponent optional targets; the parser fix is already merged in #6565.

@matthewevans
matthewevans added this pull request to the merge queue Jul 24, 2026
Merged via the queue into phase-rs:main with commit b056ad6 Jul 24, 2026
16 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.

2 participants