Skip to content

fix(engine): support "prevent N of that damage" static prevention shields#6307

Merged
matthewevans merged 4 commits into
phase-rs:mainfrom
rsnetworkinginc:fix/issue-5902
Jul 26, 2026
Merged

fix(engine): support "prevent N of that damage" static prevention shields#6307
matthewevans merged 4 commits into
phase-rs:mainfrom
rsnetworkinginc:fix/issue-5902

Conversation

@rsnetworkinginc

@rsnetworkinginc rsnetworkinginc commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Heart-Shaped Herb's own text ("If a source an opponent controls would deal
damage to you, prevent 1 of that damage.") never installed a replacement
effect at all: parse_damage_prevention_replacement's amount extraction
only recognized prevent all, prevent all but N, prevent the next N damage, and the literal prevent that damage — a bare prevent N of that damage (no "all"/"all but"/"the next" qualifier) matched none of those
branches, so the whole function returned None and the ability silently
fell through. This matches the reported symptom exactly ("isn't affecting
it at all").

This idiom is shared by several other real cards beyond Heart-Shaped Herb
(Sphere of Purity, Orbs of Warding, Urza's Armor, Guardian Seraph, Djeru —
With Eyes Open, Protection of the Hekma, Daunting Defender, Shield of the
Realm, ...), so the fix is generic rather than card-specific.

Fix

Reworked per @matthewevans's review (add-engine-variant Stage 1 =
REFUSE_WITH_EXISTING_SLOT): no new PreventionAmount variant. The
class reuses the existing shared per-event damage-subtraction authority —
DamageModification::Minus { value: N } on a DamageDone replacement, the
same continuous, non-consumed representation CR 702.64 Absorb already
synthesizes (database::synthesis::build_absorb_replacement) and applies
in Branch 1 of damage_done_applier.

  • Parser (parser/oracle_replacement.rs): "prevent N of that damage"
    now builds the replacement with damage_modification: Minus { value: N }
    (leaving shield_kind as the default None), carrying the same
    recipient/source/combat filters as before. Shield-style prevention
    (all / all-but / depleting next / redirection-context "prevent that
    damage") still routes through ShieldKind::Prevention. Only a literal
    digit after "prevent " qualifies — not the pronoun "X" — so the unrelated
    dynamic "prevent X … where X is …" idiom (Cover of Winter class) is
    untouched, and the match is anchored on the trailing "of that damage".

  • Applier bookkeeping (game/replacement.rs): the shared
    DamageModification::Minus path did not previously emit DamagePrevented.
    Fixed at that shared authority (per the review) rather than in a
    parallel arm: when a Minus modification actually reduces an event it now
    emits DamagePrevented — per-event outside a combat batch, and aggregated
    into the per-shield combat_prevention_tally inside one (a single
    post-batch DamagePrevented via fire_combat_prevention_riders, matching
    the ShieldKind::Prevention shields). CR 702.64 Absorb and the
    Minus { value: u32::MAX } prevent-all sentinel now emit it too.

CR 615.1a (prevention shield) + CR 702.64b (continuous, non-depleting) +
CR 609.7b (controller-scoped source restriction).

Closes #5902

Testing

  • 2 parser unit tests
    (heart_shaped_herb_prevent_n_of_that_damage_is_minus_modification,
    bare_prevent_n_of_that_damage_generalizes_without_source_controller_clause)
    pin the parsed damage_modification/shield_kind/damage_source_filter
    shape, including a sibling card with no controller qualifier to confirm
    the fix generalizes.
  • 1 discriminating integration test (issue_5902_heart_shaped_herb.rs)
    drives the real deal_damage::resolve pipeline: damage from an
    opponent-controlled source is reduced by 1, the shield re-fires on a
    SECOND opponent-source event (non-consumed — proving it is not a depleting
    shield), damage from the controller's own source is unaffected, and
    DamagePrevented fires for the prevented point.

Local parity (isolated target dir): cargo fmt --check clean;
cargo clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warnings clean; full engine lib suite 17567 passed /
0 failed; full integration suite 3836 passed / 0 failed.

Summary by CodeRabbit

  • New Features

    • Added support for the anchored grammar “prevent N of that damage”, enabling continuous, non-depleting prevention per qualifying damage event.
  • Bug Fixes

    • Ensured plain arithmetic minus no longer triggers prevention events or prevented-amount bookkeeping.
    • Updated prevention provenance so DamagePrevented and prevented-amount handoff work correctly, including combat-damage batching behavior.
    • Corrected Absorb-style prevention and aligned typed prevention handling during import conversions.
  • Tests

    • Added/expanded coverage for Heart-Shaped Herb, generalized prevent wording, and Benevolent Unicorn regression.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c351c9d6-07c4-42db-808a-74632a70bbcb

📥 Commits

Reviewing files that changed from the base of the PR and between 6a6b7b7 and 4e46120.

📒 Files selected for processing (1)
  • crates/mtgish-import/src/convert/replacement.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/mtgish-import/src/convert/replacement.rs

📝 Walkthrough

Walkthrough

The change introduces PreventionMinus to distinguish damage prevention from arithmetic subtraction, updates parsing, conversion, and absorb synthesis, records prevention-specific bookkeeping during damage replacement, and adds unit and integration coverage for batched and non-batched behavior.

Changes

Damage prevention flow

Layer / File(s) Summary
Prevention contract and construction
crates/engine/src/types/ability.rs, crates/engine/src/parser/oracle_replacement.rs, crates/mtgish-import/src/convert/..., crates/engine/src/database/synthesis.rs
Adds PreventionMinus, parses and converts prevention actions into it, and updates absorb replacements and shape tests to preserve prevention provenance.
Per-event damage prevention application
crates/engine/src/game/replacement.rs
Applies saturating subtraction to both variants, while only PreventionMinus emits prevention events, updates batch tallies, stamps continuation state, respects disabled prevention, and uses subtractive commutation.
Prevention behavior validation
crates/engine/tests/integration/issue_5902_heart_shaped_herb.rs, crates/engine/tests/integration/main.rs, crates/engine/src/game/replacement.rs, crates/mtgish-import/src/convert/replacement.rs
Tests distinguish arithmetic subtraction from prevention and verify parser wiring, repeated source-qualified prevention, disabled prevention, non-batched events, batched tallying, conversion mappings, and continuation bookkeeping.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OracleReplacementParser
  participant ReplacementDefinition
  participant DamageResolution
  participant ReplacementApplication
  participant GameEvent
  OracleReplacementParser->>ReplacementDefinition: construct PreventionMinus
  ReplacementDefinition-->>DamageResolution: provide damage replacement
  DamageResolution->>ReplacementApplication: apply replacement
  ReplacementApplication->>ReplacementApplication: compute prevented amount
  ReplacementApplication->>GameEvent: emit DamagePrevented outside batching
Loading

Suggested labels: bug

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The diff includes unrelated conversion changes, such as a SetTo/try_from rewrite for DealDamageInstead, that are outside the prevention-shield objective. Split the DealDamageInstead conversion cleanup into a separate PR unless it is required for the prevention-shield fix.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: supporting "prevent N of that damage" prevention shields.
Linked Issues check ✅ Passed The Heart-Shaped Herb issue is addressed by parsing and applying the prevention effect, with tests covering opponent-only damage and repeated events.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/parser/oracle_replacement.rs`:
- Around line 9802-9828: Update the after_prevent parsing branch around
parse_number to use composed nom combinators for the literal digit amount
followed immediately by the “of that damage” suffix. Remove the starts_with
filter and replace scan_contains(rest.trim_start(), "of that damage") with
anchored parsing that rejects malformed or extended clauses, while preserving
the existing dynamic “X” handling and EachEvent result.

In `@crates/engine/tests/integration/issue_5902_heart_shaped_herb.rs`:
- Around line 85-128: Extend the integration scenario around
deal_damage::resolve to create an EachEvent(2) prevention effect and apply it to
multiple opponent-source damage events. Assert each event’s damage is reduced by
exactly 2 and that the effect remains available for subsequent events, covering
both parameterized per-event arithmetic and non-depletion rather than only the
existing EachEvent(1) behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c167dab-b487-4cb0-9668-719f54818551

📥 Commits

Reviewing files that changed from the base of the PR and between 7cf9245 and fa2fdad.

📒 Files selected for processing (5)
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/parser/oracle_replacement.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/tests/integration/issue_5902_heart_shaped_herb.rs
  • crates/engine/tests/integration/main.rs

Comment thread crates/engine/src/parser/oracle_replacement.rs Outdated
Comment thread crates/engine/tests/integration/issue_5902_heart_shaped_herb.rs
@matthewevans matthewevans self-assigned this Jul 21, 2026
@matthewevans matthewevans added the bug Bug fix label Jul 21, 2026
@matthewevans matthewevans removed their assignment Jul 21, 2026
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 18 card(s), 17 signature(s) (baseline: main dd5e0286460c)

🟢 Added (14 signatures)

  • 5 cards · ➕ replacement/DamageDone · added: DamageDone (damage from=opponent, damage mod=PreventionMinus { value: 1 }, damage to=Player { player: Controller })
    • Affected (first 3): Guardian Seraph, Heart-Shaped Herb, Nothing Can Stop Me Now (+2 more)
  • 2 cards · ➕ replacement/DamageDone · added: DamageDone (damage mod=PreventionMinus { value: 1 }, damage to=Player { player: Controller })
    • Affected (first 3): Hedron-Field Purists, Urza's Armor
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (combat=CombatOnly, condition=SourceTappedState { tapped: false }, damage from=creature, damage mod=PreventionMinus { value: 1 }, damage to=Player {…
    • Affected (first 3): Thunderstaff
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (damage from=artifact, damage mod=PreventionMinus { value: 1 }, damage to=Player { player: Controller })
    • Affected (first 3): Sphere of Purity
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (damage from=black, damage mod=PreventionMinus { value: 2 }, damage to=Player { player: Controller })
    • Affected (first 3): Sphere of Grace
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (damage from=blue, damage mod=PreventionMinus { value: 2 }, damage to=Player { player: Controller })
    • Affected (first 3): Sphere of Reason
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (damage from=creature, damage mod=PreventionMinus { value: 1 }, damage to=Player { player: Controller })
    • Affected (first 3): Orbs of Warding
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (damage from=green, damage mod=PreventionMinus { value: 2 }, damage to=Player { player: Controller })
    • Affected (first 3): Sphere of Duty
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (damage from=red, damage mod=PreventionMinus { value: 2 }, damage to=Player { player: Controller })
    • Affected (first 3): Sphere of Law
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (damage from=white, damage mod=PreventionMinus { value: 2 }, damage to=Player { player: Controller })
    • Affected (first 3): Sphere of Truth
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (damage mod=PreventionMinus { value: 1 }, scope=you control creature Cleric)
    • Affected (first 3): Daunting Defender
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (damage mod=PreventionMinus { value: 1 }, scope=you control planeswalker)
    • Affected (first 3): Djeru, With Eyes Open
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (damage mod=PreventionMinus { value: 2 }, damage to=Player { player: Controller })
    • Affected (first 3): Hedron-Field Purists
  • 1 card · ➕ replacement/DamageDone · added: DamageDone (damage mod=PreventionMinus { value: 2 }, scope=equipped by self creature)
    • Affected (first 3): Shield of the Realm

🔴 Removed (3 signatures)

  • 11 cards · ➖ ability/PreventDamage · removed: PreventDamage (amount=Next(1), scope=AllDamage, target=any target)
    • Affected (first 3): Daunting Defender, Djeru, With Eyes Open, Guardian Seraph (+8 more)
  • 7 cards · ➖ ability/PreventDamage · removed: PreventDamage (amount=Next(2), scope=AllDamage, target=any target)
    • Affected (first 3): Hedron-Field Purists, Shield of the Realm, Sphere of Duty (+4 more)
  • 1 card · ➖ static/Continuous · removed: Continuous (affects=self, conditional=not (source is tapped))
    • Affected (first 3): Thunderstaff

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

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

Maintainer review complete: rules, parser architecture, runtime discrimination, parse impact, and current-head CI verified.

@matthewevans
matthewevans added this pull request to the merge queue Jul 21, 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.

Maintainer review: changes requested

[HIGH] The new PreventionAmount::EachEvent duplicates the existing parameterized per-event damage-subtraction authority. Evidence: crates/engine/src/database/synthesis.rs:3704-3715 already models CR 615 / CR 702.64 “prevent N of that damage” as DamageModification::Minus { value: n } and explicitly documents “No new variant”; crates/engine/src/types/ability.rs:19711 defines that slot, and crates/engine/src/game/replacement.rs:1609 applies its saturating subtraction to each event. This PR instead adds the same concept to the separate shield-amount axis and a parallel resolver arm.

Why it matters: the mandatory add-engine-variant Stage 1 result is REFUSE_WITH_EXISTING_SLOT. Keeping two semantic representations for the same continuous per-event subtraction splits parser and resolver authority and makes future prevention cards depend on which path happened to create them.

Suggested fix: reuse or generalize the existing DamageModification::Minus replacement pathway for this Oracle class and remove PreventionAmount::EachEvent. If that shared pathway does not currently emit the required DamagePrevented bookkeeping, fix the bookkeeping at that shared authority instead of introducing a parallel semantic representation.

Current-head CI is green, but that does not contradict the architecture finding: it verifies the duplicated path works, not that the duplicate should exist.

@matthewevans
matthewevans removed this pull request from the merge queue due to a manual request Jul 21, 2026
…elds (phase-rs#5902)

Bare "prevent N of that damage" prevention statics (Heart-Shaped Herb —
phase-rs#5902, Sphere of Purity, Orbs of Warding, Urza's Armor, Guardian Seraph,
Daunting Defender, ...) matched none of `parse_damage_prevention_replacement`'s
amount branches, so the whole static ability failed to parse into a
`ReplacementDefinition` — the reported symptom ("isn't affecting it at all").

Per the maintainer's CR review (add-engine-variant Stage 1 =
REFUSE_WITH_EXISTING_SLOT), this reuses the existing shared per-event
damage-subtraction authority rather than introducing a new
`PreventionAmount` variant:

- Parser: "prevent N of that damage" now emits a continuous
  `DamageModification::Minus { value: N }` `DamageDone` replacement — the
  same non-consumed, re-firing representation CR 702.64 Absorb already
  synthesizes (`database::synthesis::build_absorb_replacement`) — carrying
  the same recipient/source/combat filters. Shield-style prevention
  (all / all-but / next / redirection) keeps `ShieldKind::Prevention`.
- Applier: the shared `DamageModification::Minus` path (Branch 1 of
  `damage_done_applier`) now emits the `DamagePrevented` bookkeeping the
  prevention shields emit — per-event outside a combat batch, aggregated
  into the per-shield combat-damage tally inside one (fixing the
  bookkeeping at the shared authority, not a parallel path). Absorb and the
  `Minus { value: u32::MAX }` prevent-all sentinel now emit it too.

CR 615.1a + CR 702.64b + CR 609.7b.

Tests: parser unit tests for the Minus representation (opponent-scoped and
generalized N>1 recipient cases) + a discriminating integration scenario
driving `deal_damage::resolve` (reduces N per event, non-consumed across
events, opponent-source-scoped, emits DamagePrevented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rsnetworkinginc

Copy link
Copy Markdown
Contributor Author

Reworked per the 23:08Z review. Removed PreventionAmount::EachEvent entirely and re-emitted the bare "prevent N of that damage" class on the existing shared authority you pointed to: the parser now builds these shields as DamageModification::Minus { value: N } DamageDone replacements — the same continuous, non-consumed representation database::synthesis::build_absorb_replacement uses for CR 702.64 Absorb, applied in Branch 1 of damage_done_applier — carrying the same recipient/source/combat filters. No new prevention-amount variant, and no parallel resolver arm.

The one gap you flagged — the shared Minus pathway didn't emit DamagePrevented — is fixed at that shared authority rather than in a parallel path: damage_done_applier's Minus branch now emits DamagePrevented when it reduces an event (per-event outside a combat batch, aggregated into combat_prevention_tally for the single post-batch event inside one), so Absorb and the Minus { value: u32::MAX } prevent-all sentinel get it too.

Local parity on an isolated target dir, all green: fmt --check; clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warnings; full engine lib 17567 passed / 0 failed; full integration 3836 passed / 0 failed.

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/game/replacement.rs`:
- Around line 1562-1564: Replace the is_minus_prevention classification in the
replacement handling flow with explicit typed prevention provenance supplied by
the parser. Ensure only genuinely prevention-originated
DamageModification::Minus values emit DamagePrevented and activate prevention
triggers, while arithmetic reductions such as “damage minus 1” do not; preserve
the existing handling for other modification variants.
- Around line 1653-1679: Update the is_minus_prevention bookkeeping to preserve
EventContextAmount by setting state.last_effect_count to the prevented amount
for non-batched events, and ensure the combat_prevention_tally aggregate is
stamped into state.last_effect_count before fire_combat_prevention_riders runs.
Mirror the existing ShieldKind::Prevention bookkeeping while retaining the
current DamagePrevented emission and tally behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: eee5344c-373b-4828-9ab5-6b076bc5fa88

📥 Commits

Reviewing files that changed from the base of the PR and between fa2fdad and 00cf1a7.

📒 Files selected for processing (2)
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/parser/oracle_replacement.rs

Comment thread crates/engine/src/game/replacement.rs Outdated
Comment thread crates/engine/src/game/replacement.rs Outdated

@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 — the revised parser now uses the correct shared DamageModification::Minus seam, but its bookkeeping treats ordinary arithmetic reductions as prevention.

🔴 Blocker

[HIGH] DamageModification::Minus is not exclusively prevention. Evidence: crates/engine/src/game/replacement.rs:1564 classifies every Minus as prevention, while crates/engine/src/parser/oracle_replacement.rs:17403 deliberately parses Benevolent Unicorn's “damage minus 1” arithmetic replacement into the same variant. Why it matters: that card now emits DamagePrevented and can fire prevention triggers even though it prevents no damage. Suggested fix: carry typed prevention provenance from the parser/synthesis producer through the shared Minus path, and add a Benevolent-Unicorn-style negative runtime regression.

[HIGH] The new Minus prevention path omits the replacement-follow-up amount binding. Evidence: crates/engine/src/game/replacement.rs:1666 emits/tallies prevention without the last_effect_count handoff that the shield path performs at replacement.rs:1911. Why it matters: a valid “damage prevented this way” continuation can read stale or missing EventContextAmount; the combat aggregate needs the same binding before its rider fires. Suggested fix: mirror the shield path's per-event and post-batch binding semantics, with a discriminating continuation regression.

[MED] The new Oracle grammar is not an anchored nom parser. Evidence: crates/engine/src/parser/oracle_replacement.rs:9834 adds starts_with plus scan_contains, allowing a later, non-adjacent “of that damage” phrase to satisfy the branch. Why it matters: it violates the parser's combinator gate and can bind an unrelated suffix. Suggested fix: compose the existing numeric parser with an immediately following tag("of that damage") through the lower-case nom bridge.

🟡 Non-blocking

The required parse-diff evidence is stale for this head: its sticky comment is from 2026-07-21 23:01 UTC and still reports the removed EachEvent representation, while this head was committed at 2026-07-23 02:15 UTC. Please let the current card-data job publish a fresh artifact before re-review.

✅ Clean

The prior duplicate PreventionAmount::EachEvent was removed, and the Heart-Shaped Herb scenario now exercises repeated opponent-source damage plus the own-source boundary.

Recommendation: address the typed provenance and continuation-binding gaps at the shared authority, replace the ad hoc parser scan with composable nom parsing, then push a fresh head for review.

…nus path

Review round 3 on phase-rs#6307 flagged that classifying every DamageModification::Minus
as prevention made plain arithmetic replacements (Benevolent Unicorn, that much
damage minus 1) emit phantom DamagePrevented bookkeeping.

- Provenance: split the shared subtraction into typed provenance — Minus stays
  plain CR 614.1a arithmetic (no prevention bookkeeping); the new
  PreventionMinus variant carries CR 615/702.64 prevention provenance (bare
  prevent-N-of-that-damage statics, Absorb synthesis) through the SAME applier
  subtraction arm, so there is one subtraction authority with the provenance in
  the variant tag. A provenance field on Minus itself would break the dormant,
  contributor-frozen crates/mtgish-import construction sites.

- Amount binding: mirror the Branch 2 shield path at the PreventionMinus site.
  Outside a combat batch the per-event prevented amount is stamped into
  last_effect_count so a damage-prevented-this-way continuation resolves
  EventContextAmount against this event (CR 615.5); inside a batch both the
  DamagePrevented emission and the stamp defer to the post-batch aggregate via
  the per-replacement tally (CR 510.2 + CR 615.13), with the execute-template
  per-event exception and the per-source-reflecting rider exclusion mirrored.

- Anchored grammar: the prevent-N-of-that-damage amount is now a composed nom
  sequence — parse_number immediately followed by tag(" of that damage") via
  nom_parse_lower — so a non-adjacent of-that-damage phrase can no longer
  satisfy the branch.

Regressions: arithmetic-Minus negative (applier unit + Benevolent Unicorn
runtime), per-event continuation binding (stale-count discriminating), in-batch
deferral to the aggregate tally, and the parser adjacency anchor.
@rsnetworkinginc

Copy link
Copy Markdown
Contributor Author

Pushed e3960d9 addressing all three findings.

Typed provenance. The shared subtraction now carries typed provenance: Minus stays plain CR 614.1a arithmetic; the new DamageModification::PreventionMinus carries the CR 615 / CR 702.64 prevention provenance through the same applier arm (Minus { value } | PreventionMinus { value } => saturating_sub), so there is still exactly one subtraction authority. Provenance is a sibling variant tag rather than a field on Minus because the dormant crates/mtgish-import constructs Minus { value } literals that a new mandatory field would break, and CONTRIBUTING freezes that crate for contributor PRs.

Producer audit — every non-test Minus construction site:

  • parser/oracle_replacement.rs "that much damage minus N" (Benevolent Unicorn) → arithmetic Minus (the only arithmetic producer)
  • parser/oracle_replacement.rs bare "prevent N of that damage" → PreventionMinus
  • database/synthesis.rs build_absorb_replacement (CR 702.64 Absorb) → PreventionMinus
  • crates/mtgish-import/src/convert/replacement.rs:418/:425 (PreventThatDamage → Minus { u32::MAX }; PreventSomeOfThatDamage → Minus { N }) — semantically prevention, but dormant and contributor-frozen, so left as-is; a one-word maintainer-side change if that crate is revived. No other construction sites exist.

Amount binding. The PreventionMinus site now mirrors the Branch 2 shield path: outside a batch it emits DamagePrevented and stamps the per-event prevented amount into last_effect_count (CR 615.5); inside a batch both the emission and the stamp defer to the post-batch aggregate via the per-replacement tally consumed by fire_combat_prevention_riders (CR 510.2 + CR 615.13), with the execute-template per-event stamp and the per-source-reflecting rider exclusion mirrored.

Anchored grammar. The amount branch is now terminated(parse_number, tag(" of that damage")) through nom_parse_lower — the anchor must be immediately adjacent to the number.

Each regression was verified red-first: with the provenance split reverted, arithmetic Minus emitted DamagePrevented (applier unit test + Benevolent Unicorn runtime test both fail); without the stamp, the continuation test observes a seeded stale count (Some(999) instead of Some(2)); under the previous scan, the anchor test binds a non-adjacent "of that damage". At this head: engine lib 17571 passed / 0 failed, integration 3837 passed / 0 failed; cargo fmt --check, check-engine-authorities.sh, and check-parser-combinators.sh all pass. The parser change does not alter the claimed coverage class (literal-digit "prevent N of that damage"); the card-data job will publish a fresh parse-diff artifact for this head.

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

🧹 Nitpick comments (1)
crates/engine/tests/integration/issue_5902_heart_shaped_herb.rs (1)

58-166: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Batch-aggregation path for PreventionMinus is only unit-tested, not exercised through real combat.

This scenario drives deal_damage::resolve directly for non-combat damage; the combat_prevention_tally / fire_combat_prevention_riders aggregation path for PreventionMinus (unit-tested in replacement.rs with a hand-built combat_prevention_tally) is never driven through an actual combat-damage step here. Since Heart-Shaped Herb-class prevention plausibly applies to combat damage from multiple simultaneous attackers, a scenario-runner test (declare attackers → block → combat_damage()) asserting a single aggregated DamagePrevented would close the gap between the unit test's synthetic state and production wiring.

As per path instructions, "Test adequacy is the highest-frequency contributor finding" for crates/engine/tests/**, and a test should "drive the engine through its production pipeline (...combat declaration/replacement/scenario runner)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/tests/integration/issue_5902_heart_shaped_herb.rs` around lines
58 - 166, Extend
heart_shaped_herb_prevents_one_from_opponent_sources_not_own_and_never_depletes
to exercise combat through the scenario runner: declare multiple opponent
attackers, assign blocks as needed, and resolve combat via combat_damage().
Assert that the Heart-Shaped Herb reduces the simultaneous combat damage and
emits one aggregated DamagePrevented event, while preserving the existing
non-combat, ownership, and non-depletion assertions.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/engine/tests/integration/issue_5902_heart_shaped_herb.rs`:
- Around line 58-166: Extend
heart_shaped_herb_prevents_one_from_opponent_sources_not_own_and_never_depletes
to exercise combat through the scenario runner: declare multiple opponent
attackers, assign blocks as needed, and resolve combat via combat_damage().
Assert that the Heart-Shaped Herb reduces the simultaneous combat damage and
emits one aggregated DamagePrevented event, while preserving the existing
non-combat, ownership, and non-depletion assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 66a9da75-d24e-4da0-b2ca-e432e130dc56

📥 Commits

Reviewing files that changed from the base of the PR and between 00cf1a7 and e3960d9.

📒 Files selected for processing (5)
  • crates/engine/src/database/synthesis.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/parser/oracle_replacement.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/tests/integration/issue_5902_heart_shaped_herb.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/engine/src/parser/oracle_replacement.rs

@matthewevans

Copy link
Copy Markdown
Member

Held — current-head implementation review is clean; enqueue remains gated on current CI evidence.

✅ Clean

At e3960d981a47464fa2b308133df1a6b4a9a919f9, the typed PreventionMinus provenance keeps the shared subtraction authority while separating prevention bookkeeping from ordinary arithmetic Minus; the parser is now anchored with the existing nom combinators, and the added runtime cases exercise both provenances and the continuation binding.

🟡 External gate

The required Rust/card-data checks are still running, so the parse-diff sticky artifact is not yet current for this head. No contributor action is requested.

Recommendation: wait for the current check run and fresh parse-diff, then route the clean head to the authorized handler.

@matthewevans matthewevans self-assigned this Jul 26, 2026
@matthewevans matthewevans removed their assignment Jul 26, 2026

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/mtgish-import/src/convert/replacement.rs`:
- Around line 424-426: Update the fixed-value guard in the QuantityExpr handling
for DamageModification to use u32::try_from(value) rather than the invalid i32
range cast, preserving successful conversion for nonnegative values and the
existing error paths for values that cannot convert.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: df054150-5a7f-4e81-876f-fedc38240207

📥 Commits

Reviewing files that changed from the base of the PR and between e3960d9 and 6a6b7b7.

📒 Files selected for processing (4)
  • crates/engine/src/game/replacement.rs
  • crates/engine/tests/integration/issue_5902_heart_shaped_herb.rs
  • crates/mtgish-import/src/convert/mod.rs
  • crates/mtgish-import/src/convert/replacement.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/engine/tests/integration/issue_5902_heart_shaped_herb.rs
  • crates/engine/src/game/replacement.rs

Comment thread crates/mtgish-import/src/convert/replacement.rs Outdated
@matthewevans matthewevans self-assigned this Jul 26, 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 at 4e46120: current CI, parse-diff, and resolved review threads verified; the fixed conversion regression remains covered by the production conversion entry test.

@matthewevans
matthewevans added this pull request to the merge queue Jul 26, 2026
@matthewevans matthewevans removed their assignment Jul 26, 2026
Merged via the queue into phase-rs:main with commit 652ca8b Jul 26, 2026
15 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.

Heart-shaped herb does not reduce damage — [[heart-shaped herb]] should reduce incoming player damage by 1, but isn't a…

2 participants