Skip to content

fix(engine): skip the legality clone for self-sacrifice mana costs - #7558

Merged
matthewevans merged 2 commits into
mainfrom
ship/cheapgate-self-sacrifice-mana-costs
Aug 20, 2026
Merged

fix(engine): skip the legality clone for self-sacrifice mana costs#7558
matthewevans merged 2 commits into
mainfrom
ship/cheapgate-self-sacrifice-mana-costs

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 20, 2026

Copy link
Copy Markdown
Member

What this fixes

On a real 4-player Commander board (676 objects, 193 Treasure tokens) derive_display_state took 2233 ms per resolution. The mana-availability display sweep clones the whole GameState once per mana source whose cost the cheap gate cannot conclusively decide — and Treasure's {T}, Sacrifice this: Add one mana of any color is exactly that shape, because AbilityCost::Sacrifice is unconditionally uncovered by all_components_cheap_gate_covered.

Cost was O(N² × battlefield): layers_full grew exactly as N(N+1)/2, because each resolution adds a mana source that the next sweep re-clones.

The change

One module-private predicate at the mana_abilities.rs decision seam widens the skip to whole-tree choice-free costs that sacrifice exactly the ability's own source, reusing the existing mana_sources::has_unambiguous_self_sacrifice_component classifier. Two conservative guards keep the answer identical:

  • CR 601.2g — reserved source, reusing the payment path's own cost_sacrifices_reserved_source rather than re-deriving it.
  • CR 118.3 + CR 601.2h — an O(1) static-presence decline whenever any CantPayCost static is in play, because the readiness gate evaluates player_cant_sacrifice_as_cost on the pre-payment state while payment re-evaluates it after this tree's {T} component has already tapped the source.

Both guards decline into today's exact simulation, so a spurious guard costs performance and never correctness.

The classifier is additionally bounded to a flat tree carrying exactly one Sacrifice(SelfRef, Count{1}) leaf and at most one Tap leaf. Multi-consuming-leaf trees would otherwise flip the answer in the unsafe direction: the payer flattens and pays one leaf at a time, so a second consuming leaf errors after the first already mutated the source.

The flatness clause is required for soundness, not tidiness. A flattened census alone widens the classifier onto nested Composite[Composite[Tap, Sac]], because the same one-level blindness sits upstream in the readiness gate's has_tap_component — so a tapped source with a nested tree clears readiness, and removing the blindness on the classifier side alone would let it fast-path true while the payer errors on the flattened {T}. The resulting acceptance set is a strict subset of the previous one, so every consumer can only move toward pre-fix behavior.

SacrificeRequirement::Aggregate and Count{n>1} remain excluded by construction via the literal Count { count: 1 } pattern; no guard was added for them. No card-name or token-name matching. types/ability.rs is untouched.

Measured result

Perf counters — deterministic, and what the committed tests assert — on a synthetic storm at six board sizes:

prov/filler objects legality_clones layers_full
5/0 6 15 → 0 15 → 0
10/0 11 55 → 0 55 → 0
25/0 26 325 → 0 325 → 0
25/200 226 325 → 0 325 → 0
50/200 251 1275 → 0 1275 → 0
50/400 451 1275 → 0 1275 → 0

Base is exactly N(N+1)/2 on both counters; the candidate is exactly 0 at every size.

Clean back-to-back wall-clock on an idle machine, base vs candidate: ~4–5× on the large boards (50/200: 6755 ms → 1262 ms; 50/400: 10405 ms → 2538 ms). Small boards are noise-dominated single trials; the counters are the criterion.

Verification

  • Plan review clean (0 blocking, 0 material gaps); implementation review clean (0 HIGH, 0 MED).
  • Completion verification in a detached worktree at the candidate SHA: 19,462 lib + 5,277 integration tests, clippy -D warnings, rustfmt --check — all green.
  • Parser projection run both rounds: parse diff empty, card-data.json byte-identical across base and candidate. No coverage impact.
  • Six revert probes confirm each clause is load-bearing — removing any one turns exactly the intended test red.

Known limitation, documented in code

The CantPayCost guard keys on board-global static presence, not per-source. A single such permanent (Yasharn, Impeccable Sire) returns every mana source on the board to the simulation path, so the fix goes inert on that archetype. A narrowing that would keep tapless sources fast-pathed is filed as a follow-up rather than adopted here, because it depends on a hop that was not verified.

Summary by CodeRabbit

  • Performance

    • Improved mana-source legality checks by avoiding unnecessary internal simulations for eligible self-sacrificing sources.
  • Bug Fixes

    • Preserved accurate handling for complex, multi-part sacrifice costs and other cases requiring full legality checks.
    • Prevented invalid fast-path decisions for sources with multiple sacrifice or tap requirements.
  • Tests

    • Added regression coverage confirming correct mana display behavior, clone reduction, and unpayable-cost handling.

The mana-availability display sweep cloned the whole GameState once per
mana source whose cost the cheap gate could not conclusively decide.
Treasure's "{T}, Sacrifice this: Add one mana of any color" is exactly
that shape: AbilityCost::Sacrifice is unconditionally uncovered by
all_components_cheap_gate_covered, so every Treasure fell through to
can_activate_mana_ability_by_simulation and a full state clone.

On a real 4-player Commander board (676 objects, 193 Treasure tokens)
derive_display_state took 2233 ms per resolution, with layers_full
growing exactly as N(N+1)/2 -- O(N^2 x battlefield), because each
resolution adds a mana source that the next sweep re-clones.

Widen the skip to whole-tree choice-free costs that sacrifice exactly
the ability's own source, reusing the existing classifier
mana_sources::has_unambiguous_self_sacrifice_component. Aggregate
requirements, Count{n>1} and every non-SelfRef target are excluded by
construction by that predicate's SelfRef / Count{1} match -- a non-self
sacrifice may have no legal victim, so its simulation stays load-bearing.

Two conservative guards keep the answer identical:

* CR 601.2g -- a source already committed to a pending spell's deferred
  additional sacrifice cost is reserved, and paying this ability's cost
  would then error. Reuses the payment path's own authority,
  cost_sacrifices_reserved_source.
* CR 118.3 + CR 601.2h -- the readiness gate evaluates
  player_cant_sacrifice_as_cost on the pre-payment state, but payment
  re-evaluates it after this tree's {T} component has already tapped the
  source, and a prohibition's object filter can read that tapped bit.
  An O(1) static-presence read declines the fast path whenever any
  CantPayCost static is in play.

Both guards decline into today's exact simulation, so a spurious guard
costs performance and never correctness.

CR 616.1 needs no guard: a replacement on the sacrifice's battlefield to
graveyard move makes sacrifice_permanent return NeedsReplacementChoice,
which the self-sacrifice payment arm maps to Ok(Paused), so the
simulation returns Ok and reports the same answer the fast path does.
The self-sacrifice cheap-gate widening admitted cost trees carrying more
than one consuming leaf. has_unambiguous_self_sacrifice_component was
whole-tree cost_component_choice_free AND cost_has_component, and
cost_has_component requires only that at least one self-sacrifice leaf be
present -- it imposes no arity bound. Because the payer flattens the tree
and pays one leaf at a time, a second consuming leaf re-enters its arm
after the first has already mutated the source:

  Composite[Sacrifice(SelfRef,1), Sacrifice(SelfRef,1)]
    -> the second leaf hits sacrifice_permanent's not-on-battlefield Err
  Composite[Tap, Tap, Sacrifice(SelfRef,1)]
    -> the second Tap hits tap_source's already-tapped Err

In both, the simulation answers false while the fast path answered true --
a change of ANSWER, not merely of cost, in the unsafe direction.

Bound the classifier to a flat tree carrying exactly one
Sacrifice(SelfRef, Count{1}) leaf and at most one Tap leaf. The census
recurses so it counts the same multiset append_mana_ability_cost_components
builds; a census modelled on cost_has_component's one-level shape would
report 1 where the payer pays 2.

The flatness clause is required for soundness, not tidiness. A flattened
census alone WIDENS the classifier onto nested
Composite[Composite[Tap, Sacrifice(SelfRef,1)]], which today is rejected
only by cost_has_component's one-level blindness. That same blindness sits
upstream in the readiness gate, which keys its tapped and summoning-sick
checks on the one-level has_tap_component -- so a tapped source with a
nested tree clears readiness, and removing the blindness on the classifier
side alone would let it fast-path to true while the payer errors on the
flattened {T}. Requiring flatness keeps the acceptance set a strict subset
of the previous one, so every consumer can only move toward pre-fix
behavior.

SacrificeRequirement::Aggregate and Count{n>1} remain excluded by
construction through the literal Count{count:1} pattern; no guard was added
for them.

Also document that the CantPayCost guard keys on board-global static
presence rather than on the source, so a single such permanent returns
every mana source on the board to the simulation path.
@matthewevans
matthewevans enabled auto-merge August 20, 2026 01:01
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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: ee2b773c-736c-43ff-a306-0814942c04e8

📥 Commits

Reviewing files that changed from the base of the PR and between 710ab28 and fcaeaed.

📒 Files selected for processing (4)
  • crates/engine/src/game/mana_abilities.rs
  • crates/engine/src/game/mana_sources.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/mana_display_self_sacrifice_clone_gate.rs

Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

The PR adds a guarded fast path for eligible self-sacrifice mana costs. It strengthens cost classification with flattened leaf counts and adds unit and integration tests for clone elimination and conservative simulation fallback.

Changes

Self-sacrifice legality optimization

Layer / File(s) Summary
Bound self-sacrifice cost classification
crates/engine/src/game/mana_sources.rs
The classifier now requires flat costs, exactly one self-sacrifice leaf, and at most one tap leaf. New helpers flatten and inspect cost trees. Tests cover hostile and nested cost shapes.
Guarded legality-simulation bypass
crates/engine/src/game/mana_abilities.rs
Mana-ability readiness skips redundant legality-state clones for eligible self-sacrifice costs. Reserved sources, prohibiting statics, replacement effects, and non-self sacrifices retain simulation.
Clone-gate integration coverage
crates/engine/tests/integration/main.rs, crates/engine/tests/integration/mana_display_self_sacrifice_clone_gate.rs
Integration tests verify zero clones for eligible sources, simulation for non-self sacrifices, and simulation rejection for multi-leaf costs at two board sizes.

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

Merge Risk: ⚪ Minimal · up to fcaea

The change narrows legality simulation for a bounded class of self-sacrifice mana costs while retaining the existing simulation path for guarded cases; the supplied tests and checks pass, and no actionable merge-blocking risk remains beyond normal review.

Sequence Diagram(s)

sequenceDiagram
  participant ManaAbilityReadiness
  participant legality_simulation_is_redundant
  participant GameState
  ManaAbilityReadiness->>legality_simulation_is_redundant: evaluate cost and board guards
  legality_simulation_is_redundant->>GameState: check reservations and CantPayCost statics
  alt redundant
    ManaAbilityReadiness-->>ManaAbilityReadiness: return readiness without cloning
  else not redundant
    ManaAbilityReadiness->>GameState: clone and simulate legality
    GameState-->>ManaAbilityReadiness: return simulated result
  end
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: mike-thedude, kiannidev, invalidcards

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: skipping legality-state clones for self-sacrifice mana costs.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ship/cheapgate-self-sacrifice-mana-costs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@matthewevans
matthewevans added this pull request to the merge queue Aug 20, 2026
@github-actions

Copy link
Copy Markdown

Generated for head fcaeaeddf6389f26bad5c89c5cb28a78b5f2e7db.

Parse changes introduced by this PR

✓ No card-parse changes detected.

Merged via the queue into main with commit fb6bb00 Aug 20, 2026
15 checks passed
@matthewevans
matthewevans deleted the ship/cheapgate-self-sacrifice-mana-costs branch August 20, 2026 01:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant