Skip to content

fix(engine): re-derive target-dependent cost surcharge on the X+distribute casting route#5556

Merged
matthewevans merged 2 commits into
phase-rs:mainfrom
rykerwilliams:fix/distribute-x-cost-surcharge-timing
Jul 11, 2026
Merged

fix(engine): re-derive target-dependent cost surcharge on the X+distribute casting route#5556
matthewevans merged 2 commits into
phase-rs:mainfrom
rykerwilliams:fix/distribute-x-cost-surcharge-timing

Conversation

@rykerwilliams

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #5545 (Fireball's parser misparse — that PR makes strive_cost correctly get populated). This PR fixes a separate, independent runtime bug: even with strive_cost populated, [[Fireball]]'s ({X}{R}: "This spell costs {1} more to cast for each target beyond the first.\nFireball deals X damage divided evenly, rounded down, among any number of targets.") actual in-game cost never scaled with target count.

Root cause

Fireball's specific casting shape — an {X} cost combined with "divided evenly among any number of targets" — triggers a sequence where:

  1. {X} is announced first (CR 601.2b) and the cost gets locked in via apply_post_x_cost_modifiers before any targets exist, so the per-target surcharge loop (for _ in 1..target_count) is a no-op at that point (target_count == 0).
  2. Targets are chosen after (CR 601.2c).
  3. Because the spell needs to "divide" its effect among the now-known targets, the engine pauses at WaitingFor::DistributeAmong (CR 601.2d) rather than proceeding straight to payment.
  4. The GameAction::DistributeAmong handler resumes by calling casting_costs::finalize_cast directly with the stale, pre-target cost — completely skipping check_additional_cost_or_pay_with_distribute, the only place apply_target_dependent_cost_modifiers (the CR 601.2f surcharge authority) actually runs.

Fix

Route through casting_costs::finish_pending_cast_cost_or_pay — the same cost-determination authority every other casting route already uses to go from "targets known" to "cost determined, locked in, paid" — instead of calling finalize_cast directly. This is a general seam fix, not hardcoded to Fireball: Fireball is currently the only real card in Magic combining an {X} cost, a strive-shaped per-target surcharge, and a distribute-among-targets effect (verified via Scryfall across two rounds of review), so the fix automatically covers any future card with this shape.

This requires a rollback wrapper. state.pending_cast is .take()n (set to None) before the call, and finish_pending_cast_cost_or_pay's downstream chain has no restore-on-error of its own. If the recomputed (correctly higher) cost turns out unpayable, a bare Err would leave state.waiting_for stale as DistributeAmong while pending_cast is gone — a resubmitted DistributeAmong action would then silently fall through to an unrelated resolution-time-continuation branch instead of being cleanly rejected. The fix mirrors finalize_mana_payment's existing pending_for_restore clone-and-restore-on-Err pattern (CR 601.2h: "Unpayable costs can't be paid").

Extended defensively to a second call site found during review: engine_resolution_choices.rs's NamedChoice handler has the identical anti-pattern in its spell-cast resume branch (activation_ability_index == None) — also calls finalize_cast directly with a stale cost. No currently-shipped card was confirmed to reach it with a target-dependent cost modifier, but the fix is mechanical and the pattern is exactly the same, so it's fixed rather than left as a known latent gap.

A second near-miss (finalize_mana_payment's two pending.distribute-gated blocks) was investigated and found provably unreachable for every real card today — git-verified that the #2856 divide-softlock gate (ability_distribution_pool_needs_chosen_x) predates and is an ancestor of the commits shaping those blocks. Documented with a cross-reference comment; no functional change needed there.

Also corrects DistributionUnit::EvenSplitDamage's doc comment, which incorrectly claimed it always bypasses WaitingFor::DistributeAmong — true only for the non-deferred-target-selection flow, not for a deferred-selection card like Fireball.

Test note

The new test pins the surcharge via a new with_strive_cost test builder rather than parsing Fireball's full real Oracle text, because that text currently mis-parses on origin/main into an unrelated, separate bug (a spurious static cost modifier) — that's PR #5545's territory, not this branch's job, and is documented in the test module doc.

Files changed

  • crates/engine/src/game/engine.rs — the primary fix (DistributeAmong handler)
  • crates/engine/src/game/engine_resolution_choices.rs — the identical fix applied to the NamedChoice handler's spell-cast resume branch
  • crates/engine/src/game/casting_costs.rs — documentation-only cross-reference comments (no functional change)
  • crates/engine/src/types/game_state.rsDistributionUnit::EvenSplitDamage doc correction
  • crates/engine/src/game/scenario.rswith_strive_cost test builder
  • crates/engine/tests/integration/fireball_x_cost_surcharge_timing.rs (new) — discriminating runtime tests
  • crates/engine/tests/integration/main.rs — registers the new test module

CR references

CR 207.2c, CR 601.2b, CR 601.2c, CR 601.2d, CR 601.2f, CR 601.2h

Verification

  • cargo fmt --all clean
  • cargo clippy -p engine --lib -- -D warnings clean
  • ./scripts/check-parser-combinators.sh: no-op (no parser files touched)
  • cargo test -p engine --lib: 16104 passed, 0 failed
  • cargo test -p engine --test integration: 2665 passed, 0 failed
  • Live-revert-and-rerun performed independently twice (by the implementer and by an independent reviewer): reverting the cost-authority swap fails the surcharge test; reverting only the rollback wrapper (keeping the swap) fails the state-coherence assertions specifically, proving the wrapper is load-bearing, not decorative
  • Three rounds of independent plan review (closed: a test-file dependency mixup later clarified as belonging to a separate not-yet-merged PR, a genuinely-investigated near-miss call site proven unreachable via git history, a corrected card-class table, and a traced-and-fixed state-corruption risk in the naive version of this fix) plus a review-impl pass (found the second NamedChoice call site, since fixed)

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K

…tion

Fireball ({X}{R}: "This spell costs {1} more to cast for each target
beyond the first. Fireball deals X damage divided evenly, rounded
down, among any number of targets.") never actually paid the surcharge
at runtime, even with strive_cost correctly populated (separate parser
fix, PR phase-rs#5545) — its specific casting route bypassed cost re-derivation
entirely.

Root cause: {X} is announced (CR 601.2b) and the cost locked in via
apply_post_x_cost_modifiers BEFORE targets exist, so the per-target
surcharge loop is a no-op at that point. Targets are chosen after (CR
601.2c), then the "divided evenly among any number of targets" shape
pauses at WaitingFor::DistributeAmong (CR 601.2d) — and the
GameAction::DistributeAmong handler resumed by calling
casting_costs::finalize_cast directly with the stale, pre-target cost,
skipping check_additional_cost_or_pay_with_distribute entirely (the
only place apply_target_dependent_cost_modifiers, the CR 601.2f
surcharge authority, actually runs).

Fix: route through casting_costs::finish_pending_cast_cost_or_pay (the
same authority every other casting route already uses to go from
"targets known" to "cost determined, locked, paid") instead of calling
finalize_cast directly. This is a general seam fix, not Fireball-
specific — Fireball is currently the only real card combining an {X}
cost, a strive-shaped per-target surcharge, and a distribute-among-
targets effect (verified via Scryfall), so any future card with this
shape is covered automatically.

Requires a rollback wrapper: state.pending_cast is taken (set to None)
before the call, and finish_pending_cast_cost_or_pay's downstream chain
has no restore-on-error of its own — so if the recomputed (correctly
higher) cost turns out unpayable, a bare Err would leave
state.waiting_for stale as DistributeAmong while pending_cast is gone,
so a resubmitted DistributeAmong action would silently fall through to
an unrelated resolution-time continuation branch instead of being
cleanly rejected. Mirrors finalize_mana_payment's existing
pending_for_restore clone-and-restore-on-Err pattern (CR 601.2h:
"Unpayable costs can't be paid").

Applied the identical fix to a second call site with the same
anti-pattern, found during review: engine_resolution_choices.rs's
NamedChoice handler's spell-cast resume branch (activation_ability_index
== None) also called finalize_cast directly with a stale cost. No
currently-shipped card was confirmed to reach it with a target-
dependent cost modifier, but the fix is mechanical and the pattern is
identical, so it's fixed defensively rather than left as a known latent
gap.

A second near-miss (finalize_mana_payment's two pending.distribute-
gated blocks) was investigated and found provably unreachable for
every real card today (git-verified: the phase-rs#2856 gate predates and is an
ancestor of the commits shaping those blocks) — documented with a
cross-reference comment, no functional change needed there.

Also corrects DistributionUnit::EvenSplitDamage's doc comment, which
incorrectly claimed it always bypasses WaitingFor::DistributeAmong —
true only for the non-deferred-target-selection flow, not for a
deferred-selection card like Fireball.

Went through 3 rounds of independent plan review (closed: a fabricated
test-file dependency later clarified as belonging to a separate
not-yet-merged PR, a genuinely-investigated near-miss call site, a
corrected card-class table, and a traced-and-fixed state-corruption
risk in the naive version of this fix) plus a review-impl pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

rykerwilliams added a commit to rykerwilliams/phase that referenced this pull request Jul 11, 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.

Verdict: architecturally correct seam fix — approving on settled green. This routes the {X}+distribute casting resume through the single post-target cost authority instead of paying a stale pre-target cost, and it does so as a general fix, not a Fireball special-case.

✅ Clean

  • Right seam (single cost authority). engine.rs:4836 — the DistributeAmong resume previously called casting_costs::finalize_cast directly with pending.cost, the cost locked in at ChooseXValue time before targets existed, so the CR 601.2f per-target surcharge (apply_target_dependent_cost_modifiers) never ran. The fix routes through finish_pending_cast_cost_or_pay (casting_costs.rs:3159) — the same authority every post-target-selection path uses. That is the correct consolidation, not a bolt-on.
  • Softlock-preventing rollback, verified against the cited precedent. Because state.pending_cast is destructively .take()n here (unlike handle_select_targets, whose pending lives inside the TargetSelection variant), a bare Err from a recomputed-unpayable cost would strand waiting_for: DistributeAmong with pending_cast: None — a resubmitted DistributeAmong would fall through to the resolution-time continuation branch instead of being cleanly rejected. The clone-and-restore-on-Err mirrors finalize_mana_payment's pending_for_restore pattern, which I confirmed lives at casting_costs.rs:8627/8784 and 8820/8978. CR 601.2h verbatim: "Unpayable costs can't be paid."
  • Second call site fixed consistently. engine_resolution_choices.rs:3970 (the NamedChoice spell-cast resume, activation_ability_index == None) carries the identical anti-pattern and gets the identical fix + rollback. Mechanical and correct to fix rather than leave latent.
  • CR annotations all grep-verified against docs/MagicCompRules.txt: 207.2c (ability words — "strive" is in the list), 601.2b (X announced), 601.2c (targets), 601.2d (division), 601.2f (total cost locked in), 601.2h (unpayable). Accurate.
  • General test primitive + discriminating registered test. scenario.rs with_strive_cost is a reusable builder (pins strive_cost directly), not a Fireball hack. fireball_x_cost_surcharge_timing.rs drives the real ChooseX → targets → DistributeAmong sequence and asserts the mana pool reflects the per-target surcharge — fails on the pre-fix single-target cost — and isolates the #5545 parser misparse by using only the verbatim distribution clause. mod registered in main.rs.

🟡 Non-blocking

  • The casting_costs.rs / game_state.rs changes are documentation-only (cross-reference comment + DistributionUnit::EvenSplitDamage doc correction) — no functional risk. The "provably-unreachable" finalize_mana_payment near-miss is left documented rather than patched, which is the right call for a genuinely unreachable path.

Recommendation: approve + merge as bug once Rust lint + both shards + card data settle green (currently pending on head c068e099). This is a runtime defect fix (Fireball's cost never scaled with targets), so bug, not enhancement.

@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 97 card(s), 50 signature(s) (baseline: main 639cfd0737dc)

21 card(s) · ability/ChangeZone · field target: creaturein graveyard you control creature

Examples: Aphetto Dredging, Behold the Sinister Six!, Call of the Death-Dweller (+18 more)

11 card(s) · ability/ChangeZone · field target: parent targetparent target + in graveyard you control

Examples: Aether Rift, Jolted Awake, Karlach, Tiefling Berserker (+8 more)

6 card(s) · ability/ChangeZone · field target: cardin graveyard you control card

Examples: Edgewall Inn, Fraction Jackson, Long Rest (+3 more)

5 card(s) · ability/CantBlock · field target: any targetparent target

Examples: Karlach, Tiefling Berserker, Karlach, Tiefling Guardian, Karlach, Tiefling Punisher (+2 more)

5 card(s) · ability/ChangeZone · field target: artifactin graveyard you control artifact

Examples: Reconstruct History, Relive the Past, Restoration Specialist (+2 more)

4 card(s) · ability/ChangeZone · field target: creaturein graveyard creature

Examples: Aether Burst, Necromantic Selection, Starfall Invocation (+1 more)

3 card(s) · ability/ChangeZone · field target: anyin graveyard you control

Examples: Graveyard Dig, Labro Bot, Only the Best

3 card(s) · ability/ChangeZone · field target: permanentin graveyard you control permanent

Examples: "Rumors of My Death . . .", Desecrate Reality, Eerie Ultimatum

2 card(s) · ability/ChangeZone · field target: another creatureanother in graveyard you control creature

Examples: Colfenor, the Last Yew, Moorland Rescuer

2 card(s) · ability/ChangeZone · field target: instantin graveyard you control instant

Examples: Pull from the Deep, Shreds of Sanity

2 card(s) · ability/ChangeZone · field target: instant or sorceryin graveyard you control instant or in graveyard you control sorcery

Examples: Sorceress's Schemes, Spellpyre Phoenix

2 card(s) · ability/ChangeZoneAll · field target: cardin graveyard card

Examples: Bloodbond March, Sorin, Lord of Innistrad

2 card(s) · static/Continuous · field affects: another you control creature Demon or another you control creature Tieflinganother you control creature Demon or another you control creature Devil or another you control creature Imp or another…

Examples: Raphael, Fiendish Savior, Tiefling Outcasts

2 card(s) · static/Continuous · field affects: you control creature Skeleton or you control creature Zombieyou control creature Skeleton or you control creature Vampire or you control creature Zombie

Examples: A-Death-Priest of Myrkul, Death-Priest of Myrkul

2 card(s) · static/SpendManaAsAnyColor · added: SpendManaAsAnyColor (affects=player)

Examples: Mycosynth Lattice, Mycosynthwave

2 card(s) · ability/unknown · removed: unknown

Examples: Mycosynth Lattice, Mycosynthwave

1 card(s) · ability/ChangeZone · field target: Aurain graveyard you control Aura

Examples: Cass, Hand of Vengeance

1 card(s) · ability/ChangeZone · field target: another creature non-Rogueanother in graveyard you control creature non-Rogue

Examples: Body Launderer

1 card(s) · ability/ChangeZone · field target: another permanentanother in graveyard you control permanent

Examples: Puca's Covenant

1 card(s) · ability/ChangeZone · field target: artifact creaturein graveyard you control artifact creature

Examples: Technomancer

1 card(s) · ability/ChangeZone · field target: artifact or enchantment non-Auramv X- in graveyard you control artifact or mv X- in graveyard you control enchantment non-Aura

Examples: Dance of the Manse

1 card(s) · ability/ChangeZone · field target: cardin exile card

Examples: Bag of Devouring

1 card(s) · ability/ChangeZone · field target: chosen card type scoped player controls in graveyard cardchosen creature type scoped player controls in graveyard card

Examples: Grave Sifter

1 card(s) · ability/ChangeZone · field target: creaturemv 2- in graveyard you control creature

Examples: Dewdrop Cure

1 card(s) · ability/ChangeZone · field target: creaturemv 3- in graveyard you control creature

Examples: Leonardo's Technique

… 25 more signature(s) (25 card-changes) — see parse-diff.json
  • 1 card(s) · ability/ChangeZone · field target: creature Allyin graveyard you control creature Ally
  • 1 card(s) · ability/ChangeZone · field target: creature Boarin graveyard you control creature Boar
  • 1 card(s) · ability/ChangeZone · field target: mv X creaturemv X in graveyard you control creature
  • 1 card(s) · ability/ChangeZone · field target: named "~" cardnamed "~" in graveyard you control card
  • 1 card(s) · ability/ChangeZone · field target: tracked set #0tracked set #0 + in graveyard
  • 1 card(s) · ability/ChangeZone · field target: triggering sourceparent target + in graveyard
  • 1 card(s) · ability/ChangeZone · field target: triggering sourcetriggering source + in graveyard you control
  • 1 card(s) · ability/ChangeZone · field target: with cycling permanentwith cycling in graveyard you control permanent
  • 1 card(s) · ability/ChangeZone · field up_to: true
  • 1 card(s) · ability/ChangeZoneAll · field target: blackblack in graveyard
  • 1 card(s) · ability/ChangeZoneAll · field target: creaturein graveyard creature
  • 1 card(s) · ability/ChangeZoneAll · field target: named "~" cardnamed "~" in graveyard card
  • 1 card(s) · ability/ChangeZoneAll · field target: named "~" cardnamed "~" in graveyard you control card
  • 1 card(s) · trigger/ChangesZone · field condition: you control another colorless in battlefield you control creature
  • 1 card(s) · static/Continuous · field affects: another you control creature Alicorn or another you control creature Unicornanother you control creature Alicorn or another you control creature Horse or another you control creature Pegasus or a…
  • 1 card(s) · static/Continuous · field affects: another you control creature Homarid or another you control creature Merfolkanother you control creature Homarid or another you control creature Camarid or another you control creature Cephalid o…
  • 1 card(s) · static/Continuous · field affects: another you control creature Pegasus or another you control creature Horseanother you control creature Pegasus or another you control creature Unicorn or another you control creature Horse
  • 1 card(s) · static/Continuous · field affects: another you control creature Pest or another you control creature Spideranother you control creature Pest or another you control creature Bat or another you control creature Insect or another…
  • 1 card(s) · static/Continuous · field affects: another you control creature Rabbit or another you control creature Mouseanother you control creature Rabbit or another you control creature Bat or another you control creature Bird or another…
  • 1 card(s) · static/Continuous · field affects: another you control creature Spider or another you control creature Wolfanother you control creature Spider or another you control creature Boar or another you control creature Bat or another…
  • 1 card(s) · static/Continuous · field affects: you control creature Assassin or you control creature Rogueyou control creature Assassin or you control creature Mercenary or you control creature Rogue
  • 1 card(s) · static/Continuous · field affects: you control creature Each Fungus or you control creature Saprolingyou control creature Fungus or you control creature Saproling
  • 1 card(s) · ability/LoseLife · field conditional: # of another colorless in battlefield scoped player controls creature ≥ 1
  • 1 card(s) · ability/Mana · added: Mana (kind=activated, mana={G})
  • 1 card(s) · ability/Mana · removed: Mana (kind=activated, mana={G})

@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 engine fix is sound (both test shards pass), but two clippy -D warnings errors in the new test file abort the lint job and cascade the coverage-gate red.

🔴 Blocker — clippy::never_loop at crates/engine/tests/integration/fireball_x_cost_surcharge_timing.rs:122

Every arm of the match inside for _ in 0..40 diverges (return on ChooseXValue, panic! on TargetSelection, _ => return), so the loop body can never reach a second iteration — clippy reports "this loop never actually loops" and the 0..40 bound is dead. Decide the intent:

  • If a single check suffices (the state is ChooseXValue on entry), drop the loop and keep just the match.
  • If the intent is to poll until ChooseXValue appears, the _ arm must not return — it has to advance the pending state and continue, otherwise it silently returns on the first non-matching state.

🔴 Blocker — clippy::manual_is_multiple_of at fireball_x_cost_surcharge_timing.rs:192

assert!(n > 0 && total % n == 0, ...) → replace total % n == 0 with total.is_multiple_of(n) (clippy's suggested fix).

✅ Clean (unchanged from prior review)

The runtime fix itself is right-seam and CR-correct — both Rust test shards (1/2, 2/2) pass on this head. Routing through finish_pending_cast_cost_or_pay with clone-and-restore-on-Err rollback, both call sites fixed, CRs 207.2c / 601.2b–h grep-verified. No architecture changes needed.

Recommendation: fix the two lint errors on the test file (no engine change required); approve and merge as bug on settled green.

…iple_of)

drive_choose_x's for-loop never actually looped (every match arm
diverges), so clippy correctly flagged it as dead — replaced with a
single match. Also replaced total % n == 0 with total.is_multiple_of(n)
per clippy's suggestion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbgwGxbU9NHN9kou9isp8K
@rykerwilliams

Copy link
Copy Markdown
Contributor Author

Fixed in c8bfd6f.

  • `never_loop`: `drive_choose_x`'s loop never actually looped — every match arm diverges (return/panic), so the `0..40` bound was dead. Simplified to a single match, with the fallthrough arm now panicking with the actual observed state instead of silently returning.
  • `manual_is_multiple_of`: replaced `total % n == 0` with `total.is_multiple_of(n)` per the suggested fix.

Re-verified: `cargo clippy -p engine --test integration -- -D warnings` clean, and both tests (`fireball_surcharge_scales_with_target_count_on_distribute_route`, `fireball_unpayable_recomputed_cost_rolls_back_cleanly`) still pass.

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

Approving — the two clippy blockers are fixed, and the never_loop fix makes the test strictly more discriminating than before.

✅ Clean

The lint fix is a genuine test hardening, not a lint-silencer. fireball_x_cost_surcharge_timing.rs:122 — the old for _ in 0..40 { match … } had a _ => return arm that silently exited on any unexpected WaitingFor state, so the test could pass without ever driving ChooseX. The replacement makes the catch-all fail closed:

other => panic!("expected ChooseXValue immediately after CastSpell, got {other:?}"),

That converts a silent pass into a hard failure, which is exactly the right direction for a test guarding a cost-timing invariant. total % n == 0total.is_multiple_of(n) at :185 is semantically identical.

Delta is test-file-only (ahead:1, behind:0, +13/−14, single file), so the engine review from the prior pass carries unchanged:

  • Right seam — routes through finish_pending_cast_cost_or_pay (casting_costs.rs:3159), the single post-target cost authority, rather than calling finalize_cast with a cost locked in at ChooseXValue time.
  • Clone-and-restore-on-Err rollback mirroring finalize_mana_payment's pending_for_restore — load-bearing, because pending_cast is destructively taken here; without it an unpayable recomputed cost would strand waiting_for: DistributeAmong with pending_cast: None.
  • Both resume call sites fixed (engine_resolution_choices.rs:3970 NamedChoice path).
  • CR 601.2f (per-target surcharge is part of the total cost) and CR 601.2b–h grep-verified; general with_strive_cost test builder rather than a Fireball special-case.

CI fully resolved green — 12 SUCCESS, 1 skipped, both Rust shards and the lint job that was previously red.

Approving and enqueuing as bug.

@matthewevans matthewevans added the bug Bug fix label Jul 11, 2026
@matthewevans
matthewevans added this pull request to the merge queue Jul 11, 2026
Merged via the queue into phase-rs:main with commit f6432a2 Jul 11, 2026
13 checks passed
matthewevans added a commit to mike-theDude/phase that referenced this pull request Jul 11, 2026
…target-dependent cost re-derivation

PR phase-rs#5574 (defer spell sacrifice costs until mana payment) branched from
dbac6ad, before phase-rs#5556 (re-derive target-dependent cost surcharge on the
X+distribute casting route, f6432a2) landed. Both touch the cost-finalization
seam in `finalize_mana_payment` / `finalize_mana_payment_with_phyrexian_choices`,
so the branch went stale through no fault of the contributor. This rebase is
maintainer-side reconciliation, not a request for changes.

The two behaviors are orthogonal and both survive:

- phase-rs#5556 routes the `DistributeAmong` resume through
  `finish_pending_cast_cost_or_pay` so the total cost is re-derived after
  targets are known (CR 601.2f). That function is byte-identical between main
  and this branch, so its authority is preserved as-is.
- phase-rs#5574 defers a non-mana additional cost (Tinker's "sacrifice an artifact")
  past the mana window: mana abilities may be activated (CR 601.2g) before
  costs are paid (CR 601.2h), so the sacrifice is recorded at selection and
  paid at the payment commit.

The one place the two interact is the pool snapshot the distribute branch uses
to infer X. On the deferred-sacrifice route,
`pay_spell_mana_before_deferred_sacrifice` has already spent the pool by the
time that branch runs, so the snapshot is hoisted above it — reading the pool
inside the branch would infer X = 0 for a spell that is both {X}+distribute and
carries an additional sacrifice cost. The `unwrap_or_else` fallback keeps the
original read for every other route, so the hoist is a no-op for every card
that currently exists: no card in the 34,632-card MTGJSON AtomicCards pool has
{X} in its mana cost *and* a cast-time divided/distributed effect *and* an
additional cost. The hoist is therefore latent robustness, not a live fix, and
no existing test can discriminate it.

This commit documents that ordering constraint at both sites, alongside the
CR 601.2g/601.2h rationale for where the pre-payment checks now sit.

Comment-only; no functional change.

Co-authored-by: mike-theDude <mbriningstool@gmail.com>
andriypolanski pushed a commit to andriypolanski/phase that referenced this pull request Jul 11, 2026
* fix(engine): defer spell sacrifice costs until mana payment

* fix(engine): address deferred sacrifice review feedback

* chore(engine): reconcile Tinker sacrifice-deferral with phase-rs#5556 target-dependent cost re-derivation

PR phase-rs#5574 (defer spell sacrifice costs until mana payment) branched from
dbac6ad, before phase-rs#5556 (re-derive target-dependent cost surcharge on the
X+distribute casting route, f6432a2) landed. Both touch the cost-finalization
seam in `finalize_mana_payment` / `finalize_mana_payment_with_phyrexian_choices`,
so the branch went stale through no fault of the contributor. This rebase is
maintainer-side reconciliation, not a request for changes.

The two behaviors are orthogonal and both survive:

- phase-rs#5556 routes the `DistributeAmong` resume through
  `finish_pending_cast_cost_or_pay` so the total cost is re-derived after
  targets are known (CR 601.2f). That function is byte-identical between main
  and this branch, so its authority is preserved as-is.
- phase-rs#5574 defers a non-mana additional cost (Tinker's "sacrifice an artifact")
  past the mana window: mana abilities may be activated (CR 601.2g) before
  costs are paid (CR 601.2h), so the sacrifice is recorded at selection and
  paid at the payment commit.

The one place the two interact is the pool snapshot the distribute branch uses
to infer X. On the deferred-sacrifice route,
`pay_spell_mana_before_deferred_sacrifice` has already spent the pool by the
time that branch runs, so the snapshot is hoisted above it — reading the pool
inside the branch would infer X = 0 for a spell that is both {X}+distribute and
carries an additional sacrifice cost. The `unwrap_or_else` fallback keeps the
original read for every other route, so the hoist is a no-op for every card
that currently exists: no card in the 34,632-card MTGJSON AtomicCards pool has
{X} in its mana cost *and* a cast-time divided/distributed effect *and* an
additional cost. The hoist is therefore latent robustness, not a live fix, and
no existing test can discriminate it.

This commit documents that ordering constraint at both sites, alongside the
CR 601.2g/601.2h rationale for where the pre-payment checks now sit.

Comment-only; no functional change.

Co-authored-by: mike-theDude <mbriningstool@gmail.com>

---------

Co-authored-by: mike-theDude <mbriningstool@gmail.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
rykerwilliams added a commit to rykerwilliams/phase that referenced this pull request Jul 12, 2026
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