Card(s)
Bakersbane Duo. Every "Whenever you expend N" card is affected, in every game.
Build/version
phase-engine 0.50.0, base 1bb6c1d.
Game mode
P2P
Actual behavior
Expend never fires. mana_spent_on_spells_this_turn stays empty for the entire game — from a turn-7 save, the value at the end of turns 3, 4, 5 and 6, each of which contained casts:
"mana_spent_on_spells_this_turn": {}
The mechanic itself is wired correctly; the same save has the trigger parsed and ready:
{"mode": "ManaExpend", "expend_threshold": 4}
Nothing is reported in unimplemented_mechanics. The tally is simply never incremented, so no ManaExpended event is ever emitted.
Expected behavior
CR 700.14: paying a cost to cast a spell that takes the turn's mana-spent total from below N to at least N fires "Whenever you expend N".
Root cause
finalize_cast (crates/engine/src/game/casting_costs.rs) derives the amount as a mana-pool difference, snapshotting pool_before immediately before payment:
let actual_mana_spent =
prepaid_actual_mana_spent.unwrap_or_else(|| pool_before.saturating_sub(pool_after) as u32);
Under CastPaymentMode::Auto, pay_mana_cost_with_choices_and_resume taps the lands itself. The mana is produced into the pool and consumed inside the payment, between the two snapshots, so the difference is 0 for every land-paid cast. The heuristic is only correct when mana was pre-floated into the pool.
That is also why the existing expend tests pass: they fund the cast with GameScenario::with_mana_pool, which is the one case the difference measures correctly. No test pays from lands.
The same expression zeroes StackEntryKind::Spell::actual_mana_spent.
Suggested fix
Read what the payment already recorded. Both payment paths set mana_spent_to_cast_amount from the same convoke-corrected unit list (CR 702.51a) that the adjacent colors_spent_to_cast read is derived from, so this replaces the heuristic with the existing authority rather than adding a second one:
let recorded_mana_spent = state
.objects
.get(&object_id)
.map(|obj| obj.mana_spent_to_cast_amount)
.unwrap_or(0);
let actual_mana_spent = if recorded_mana_spent > 0 {
recorded_mana_spent
} else {
prepaid_actual_mana_spent.unwrap_or(0)
};
The paused deferred-life branch above computes prepaid_actual_mana_spent by the same pool difference and needs the same treatment.
Because the amount now comes from the payment, additional costs (CR 601.2f-h) are covered by construction rather than per keyword.
Verified locally: cargo test --test integration 4809 passed | 0 failed, cargo test -p phase-engine --lib 18832 passed | 0 failed, clippy clean.
Steps to reproduce
Battlefield: Bakersbane Duo and four untapped Forests. Cast any four-mana spell, paying by tapping the lands — no counter appears. Reported case: {1}{G} creature plus its {2} Offspring cost.
As a test (crates/engine/tests/integration/), the decisive detail being that the board is funded with lands and not with_mana_pool:
#[test]
fn tapping_lands_to_cast_counts_toward_mana_spent_this_turn() {
let mut scenario = GameScenario::new();
scenario.at_phase(Phase::PreCombatMain);
for _ in 0..4 {
scenario.add_basic_land(P0, ManaColor::Green);
}
let spell = scenario
.add_creature_to_hand(P0, "Four Mana Bear", 2, 2)
.with_mana_cost(ManaCost::Cost { generic: 3, shards: vec![ManaCostShard::Green] })
.id();
let mut runner = scenario.build();
let card_id = runner.state().objects[&spell].card_id;
runner.act(GameAction::CastSpell {
object_id: spell,
card_id,
targets: vec![],
payment_mode: CastPaymentMode::Auto,
}).unwrap();
runner.advance_until_stack_empty();
assert_eq!(
runner.state().mana_spent_on_spells_this_turn.get(&P0).copied().unwrap_or(0),
4,
);
}
Read the tally inside the casting turn — cleanup clears it, so settling into the next turn reads 0 regardless.
Controls: the same board with a {1}{G} spell and a {2} optional additional cost expends 4 when paid and 2 when declined (the declined case must not reach the threshold); a sweep over AdditionalCost::{Required, Optional, Kicker} carrying the same {2} gives 4 in each case.
Logs/screenshots/game-state
Turn-7 state dump available on request; the relevant extracts are quoted above.
Unrelated but adjacent: the X-spell distribution path differences the pool the same way, but only as a fallback behind chosen_x, so it is unreachable on the ChooseX path.
Diagnosed with an LLM (claude-opus-5). No PR opened: CONTRIBUTING.md routes crates/engine/ changes through /engine-implementer, which this session did not run.
Card(s)
Bakersbane Duo. Every "Whenever you expend N" card is affected, in every game.
Build/version
phase-engine0.50.0, base1bb6c1d.Game mode
P2P
Actual behavior
Expend never fires.
mana_spent_on_spells_this_turnstays empty for the entire game — from a turn-7 save, the value at the end of turns 3, 4, 5 and 6, each of which contained casts:"mana_spent_on_spells_this_turn": {}The mechanic itself is wired correctly; the same save has the trigger parsed and ready:
{"mode": "ManaExpend", "expend_threshold": 4}Nothing is reported in
unimplemented_mechanics. The tally is simply never incremented, so noManaExpendedevent is ever emitted.Expected behavior
CR 700.14: paying a cost to cast a spell that takes the turn's mana-spent total from below N to at least N fires "Whenever you expend N".
Root cause
finalize_cast(crates/engine/src/game/casting_costs.rs) derives the amount as a mana-pool difference, snapshottingpool_beforeimmediately before payment:Under
CastPaymentMode::Auto,pay_mana_cost_with_choices_and_resumetaps the lands itself. The mana is produced into the pool and consumed inside the payment, between the two snapshots, so the difference is 0 for every land-paid cast. The heuristic is only correct when mana was pre-floated into the pool.That is also why the existing expend tests pass: they fund the cast with
GameScenario::with_mana_pool, which is the one case the difference measures correctly. No test pays from lands.The same expression zeroes
StackEntryKind::Spell::actual_mana_spent.Suggested fix
Read what the payment already recorded. Both payment paths set
mana_spent_to_cast_amountfrom the same convoke-corrected unit list (CR 702.51a) that the adjacentcolors_spent_to_castread is derived from, so this replaces the heuristic with the existing authority rather than adding a second one:The paused deferred-life branch above computes
prepaid_actual_mana_spentby the same pool difference and needs the same treatment.Because the amount now comes from the payment, additional costs (CR 601.2f-h) are covered by construction rather than per keyword.
Verified locally:
cargo test --test integration4809 passed | 0 failed,cargo test -p phase-engine --lib18832 passed | 0 failed, clippy clean.Steps to reproduce
Battlefield: Bakersbane Duo and four untapped Forests. Cast any four-mana spell, paying by tapping the lands — no counter appears. Reported case:
{1}{G}creature plus its{2}Offspring cost.As a test (
crates/engine/tests/integration/), the decisive detail being that the board is funded with lands and notwith_mana_pool:Read the tally inside the casting turn — cleanup clears it, so settling into the next turn reads 0 regardless.
Controls: the same board with a
{1}{G}spell and a{2}optional additional cost expends 4 when paid and 2 when declined (the declined case must not reach the threshold); a sweep overAdditionalCost::{Required, Optional, Kicker}carrying the same{2}gives 4 in each case.Logs/screenshots/game-state
Turn-7 state dump available on request; the relevant extracts are quoted above.
Unrelated but adjacent: the X-spell distribution path differences the pool the same way, but only as a fallback behind
chosen_x, so it is unreachable on the ChooseX path.Diagnosed with an LLM (claude-opus-5). No PR opened:
CONTRIBUTING.mdroutescrates/engine/changes through/engine-implementer, which this session did not run.