Skip to content

Expend never triggers: mana paid by tapping lands is counted as zero #7232

Description

@cuinhellcat

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions