Skip to content

feat(engine): add player-to-player ExchangeLifeTotals effect#3510

Open
andriypolanski wants to merge 7 commits into
phase-rs:mainfrom
andriypolanski:feat/player-to-player-life-total-exchange
Open

feat(engine): add player-to-player ExchangeLifeTotals effect#3510
andriypolanski wants to merge 7 commits into
phase-rs:mainfrom
andriypolanski:feat/player-to-player-life-total-exchange

Conversation

@andriypolanski

@andriypolanski andriypolanski commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Cards that make two players exchange life totals were unimplemented. The existing ExchangeLifeWithStat effect covers life ↔ creature stat (Tree of Perdition, Evra), but not player-to-player swaps.

This PR adds Effect::ExchangeLifeTotals with parser + resolver coverage for:
Closes #3486

Card Oracle pattern
Soul Conduit Two target players exchange life totals.
Axis of Mortality …you may have two target players exchange life totals.
Mirror Universe / Magus of the Mirror Exchange life totals with target opponent.

Root cause

No Effect variant or parser arm recognized player-to-player exchange phrases. They fell through to Unimplemented or partial parses with no runtime handler.

Changes

  • crates/engine/src/types/ability.rsEffect::ExchangeLifeTotals { player_a, player_b } + EffectKind::ExchangeLifeTotals
  • crates/engine/src/parser/oracle_effect/imperative.rstry_parse_exchange_life_totals for both grammatical shapes
  • crates/engine/src/game/effects/exchange_life.rsresolve_life_totals with CR 701.12a all-or-nothing + CR 119.5 gain/loss-to-reach (mirrors ExchangeLifeWithStat can't-gain/can't-lose handling)
  • crates/engine/src/game/ability_utils.rs — two player target slots (Controller implicit, no slot)
  • crates/engine/tests/integration/issue_3486_exchange_life_totals.rs — parser + resolver regressions

Rules

  • CR 701.12a: Exchange life totals — all-or-nothing when any part can't complete
  • CR 119.3 / CR 119.5: Life changes are gain/loss to reach the other total
  • CR 119.7 / CR 119.8: Can't gain / can't lose blocks the exchange for that player

Test plan

  • cargo test -p engine --test integration issue_3486_exchange_life_totals
  • cargo test -p engine --lib exchange_life_totals
  • Tilt: clippy, test-engine green
  • Manual: Soul Conduit activated ability — pick two players, life totals swap

@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 implements player-to-player life total exchanges (Effect::ExchangeLifeTotals) in accordance with CR 701.12a, including parser support, targeting, resolution logic, and integration tests. Feedback highlights a critical rules violation where life loss from an exchange is incorrectly treated as damage (violating CR 119.5), which could trigger damage-related abilities or prevention effects; using a non-damage life loss helper is recommended.

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.

for (player_id, diff) in [(player_a_id, old_b - old_a), (player_b_id, old_a - old_b)] {
let deferred = match diff.signum() {
1 => apply_life_gain(state, player_id, diff as u32, events).err(),
-1 => apply_damage_life_loss(state, player_id, (-diff) as u32, events).err(),

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

[HIGH] Treating life exchange loss as damage. Evidence: crates/engine/src/game/effects/exchange_life.rs:119.
Why it matters: CR 119.5 states that life loss from an exchange is not damage and cannot be prevented as such. Using apply_damage_life_loss may incorrectly trigger damage-related abilities or apply damage prevention. Suggested fix: Use apply_life_loss or a non-damage life loss helper instead.

Suggested change
-1 => apply_damage_life_loss(state, player_id, (-diff) as u32, events).err(),
-1 => apply_life_loss(state, player_id, (-diff) as u32, events).err(),
References
  1. Strict fidelity to the MTG Comprehensive Rules (CR) — every game rule, validation, and computed value matches the CR exactly. Treating life exchange loss as damage violates CR 119.5. (link)

@andriypolanski andriypolanski force-pushed the feat/player-to-player-life-total-exchange branch from 29701fa to ec0ecc3 Compare June 16, 2026 15:53
andriypolanski and others added 3 commits June 16, 2026 09:08
Partial prefix matching on compound Oracle lines (Profane Transfusion,
Mister Negative) left trailing clauses as swallowed diagnostics and
failed the card-data coverage regression ratchet (+2 swallowed-clause).

Co-authored-by: Cursor <cursoragent@cursor.com>

@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 — feat(engine): add player-to-player ExchangeLifeTotals effect

Verdict: ready to enqueue once rebased on main (mergeState is BEHIND). One non-blocking CR-precision nit below.

Architecture / seam

Right seam, idiomatic. ExchangeLifeTotals is added as a new Effect enum variant (not a new field on an existing variant), so it slots into every existing exhaustive match without forcing a programmatic escape hatch. Categorical boundary is respected — life-total exchange stays within the CR 119 / 701.12 life-rule section; no cross-section unification with power/toughness.

add-engine-effect lockstep is complete and was verified site-by-site:

  • types: Effect variant + EffectKind + From<&Effect> + effect_variant_name
  • parser: try_parse_exchange_life_totals (nom all_consuming/terminated/alt/value, no string dispatch) + IR ImperativeFamilyAst + chain dispatch ✔
  • resolver: exchange_life::resolve_life_totals, all-or-nothing gate via life_change_blocked
  • targeting: both collect_target_slots and collect_target_slot_specs wired for the two participant filters ✔
  • multiplayer/coverage: coverage.rs effect_details + printed_cards.rs walk_effect + trigger_index.rs + clause_is_dig_lookback_transparent
  • AI: redundancy_avoidance no-static-redundancy arm ✔

Gemini's HIGH finding is already resolved at HEAD

Gemini flagged "life exchange loss treated as damage (CR 119.5), use apply_life_loss". That is exactly what this diff does: apply_damage_life_loss is split — apply_life_loss is the CR 119.3 non-damage primitive and the exchange uses it; apply_damage_life_loss is retained as a thin CR 120.3 delegator for the actual damage path. Confirmed against HEAD; no further action needed.

Tests discriminate

exchange_life_totals_swaps_two_players / ..._blocked_when_either_player_cant_gain and the issue_3486_* integration tests drive resolve() and would fail if the swap or the all-or-nothing gate regressed. Parser tests pin the swallowed-clause ratchet for the compound Profane Transfusion / Mister Negative lines.

Non-blocking nit — CR sub-rule precision

The new code annotates the life-total exchange as CR 701.12a (the generic "a spell may instruct players to exchange something"). The precise sub-rule for this mechanic is CR 701.12c ("When life totals are exchanged, each player gains or loses the amount of life necessary to equal the other's life total"). Worth tightening 701.12a701.12c on the ExchangeLifeTotals doc-comment, the resolver resolve_life_totals doc, and the parser comment. Not a correctness issue — purely annotation precision — so it does not block.

Enqueue note

mergeStateStatus: BEHIND (no textual conflict). Merge origin/main in before enqueue so the queue can speculate cleanly.

@matthewevans matthewevans added the enhancement New feature or request label Jun 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.

Adversarial second-pass — CHANGES REQUESTED (parser-combinator gate red)

Thanks for this — the Effect::ExchangeLifeTotals design is clean and the add-engine-effect lockstep is complete. Two things block the enqueue: a hard parser-gate failure on the current head, and a pervasive CR-annotation error. This review supersedes the prior approval state.

Architecture verdict: PASS (with a CR-annotation fix required)

  • ExchangeLifeTotals is a new Effect variant (not a field), so it's safe against the mtgish wildcard — confirmed.
  • add-engine-effect lockstep is wired end-to-end: resolver (exchange_life.rs), targeting (collect_target_slots + collect_target_slot_specs), coverage (effect_details), trigger index, multiplayer/AI (redundancy_avoidance), serde round-trip via #[serde(default)]. Exhaustive match arms all updated. Good.
  • The apply_damage_life_lossapply_life_loss rename + delegation correctly routes the exchange through the CR 119.3 non-damage life-loss path (Gemini's "loss-as-damage" concern is fixed at this head). All-or-nothing pre-check (life_change_blocked for both players before any mutation) is correct per CR 701.12a + 119.7/119.8.
  • Runtime tests are discriminating: exchange_life_totals_swaps_two_players and ..._blocked_when_either_player_cant_gain drive resolve() and would fail if reverted; integration test issue_3486_exchange_life_totals.rs covers parse + resolve for both phrase shapes.

Blocker 1 (fatal): nom-combinator mandate violation

Rust lint (fmt, clippy, parser gate) is RED. The parser-combinator gate flags an added line:

crates/engine/src/parser/swallow_check.rs:
      if stripped.contains("if you lost life this way")

.contains(...) for parsing dispatch violates the hard CLAUDE.md nom-combinator rule. Two acceptable fixes:

  • Rewrite the dispatch with a nom_primitives::scan_contains / combinator form (preferred), or
  • If this is genuinely a structural guard on a pre-cleaned string (it is gating on a normalized stripped chunk), annotate the line with // allow-noncombinator: <reason> per PATTERNS.md §9.

Blocker 2 (must fix): wrong CR subpart throughout

Every annotation in this PR cites CR 701.12a for life-total exchange, but 701.12a is the generic "if the entire exchange can't be completed, no part occurs" rule. The rule that actually governs life-total exchange (gain/lose to equal the other player's previous total, with the can't-gain/can't-lose riders you implement) is CR 701.12c:

701.12c When life totals are exchanged, each player gains or loses the amount of life necessary to equal the other player's previous life total. … A player who can't gain life can't be given a higher life total this way … (see rules 119.7–8).

Please retarget the citations to CR 701.12c (keeping CR 701.12a only where you specifically mean the all-or-nothing completion guard, and CR 119.3/119.5/119.7/119.8 where already used). The reviewer noted this as a non-blocking nit, but since it's pervasive (~15 sites: variant doc-comment, resolver, parser, targeting, tests) and the repo treats CR-annotation accuracy as a hard gate, fold it into the same revision.

To unblock

  • Fix the swallow_check.rs combinator-gate line (rewrite or // allow-noncombinator:).
  • Sweep 701.12a701.12c for the life-total-exchange citations.

Once Rust lint is green on the head, I'll approve and enqueue.

andriypolanski and others added 2 commits June 16, 2026 11:31
Life-with-stat exchange is CR 701.12g (numerical value exchange), not
701.12a/701.12c. Keep 701.12a only for all-or-nothing completion guards.
Regenerate engine-inventory from updated variant docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

"Two target players exchange life totals" / "exchange life totals with target opponent" is unimplemented

2 participants