Skip to content

Declining an optional discard replacement drops the remaining discards of a multi-card discard #7510

Description

@lgray

🤖 AI text below 🤖

Summary

Declining an optional discard replacement (Library of Leng) during a forced-count multi-card discard (Balance) causes every discard after the first to be silently dropped. The player is instructed to discard 2 cards; 1 leaves the hand. Only one replacement prompt fires where two are owed.

This is pre-existing on main and independent of any in-flight PR — measured below on c0da2db9c (v0.57.0).

CR 701.9a: "To discard a card, move it from its owner's hand to that player's graveyard." Balance instructs each player to discard down to the fewest cards in a hand; with hands of 3 and 1, P0 owes 2 discards and must finish at 1. It finishes at 2.

Note the replacement here is declined, so CR 614.6 ("If an event is replaced, it never happens") should not be engaged at all — the discard should proceed unmodified. The card is lost from the batch rather than replaced.

Measured

Same tree, same test, one variable — c0da2db9c, 2-player, P0 hand 3, P1 hand 1, P0 controls Library of Leng, Balance cast for free, all replacements declined:

fixture prompts P0 hand verdict
Balance + Library of Leng, declined 1 2 wrong — one discard lost
Balance alone, no Library of Leng 0 1 correct

The optional replacement is the only variable. A save/restore round-trip inserted at the pause changes nothing (measured separately on a PR head: identical prompts=1 p0=2), so this is not a persistence issue.

Instrumented pause trace, same fixture:

[0] DiscardChoice(count=2)  batch=false  snap=true   p0=3 p1=1
[1] ReplacementChoice       batch=false  snap=true   p0=3 p1=1
[2] Priority                batch=false  snap=true   p0=2 p1=1

After the single ReplacementChoice is declined, the flow proceeds to Priority with P0 at 2 cards and never returns to discard the second selected card. Also visible: Balance's selected-card discard never parks a pending_discard_batch (batch=false at every pause), unlike a forced whole-hand discard, which may be where the remaining-count state is lost.

Reproduction

Append to crates/engine/tests/integration/balance_equalization.rs (uses that file's existing BALANCE_ORACLE and hand_len). It currently panics with the observed values; delete the add_creature_from_oracle line to see the correct control.

#[test]
fn repro_optional_discard_replacement_drops_remaining_discard() {
    use engine::game::scenario::{GameScenario, P0 as SEAT0, P1 as SEAT1};
    use engine::types::actions::GameAction;
    use engine::types::mana::ManaCost;
    use engine::types::phase::Phase;

    const LENG: &str = "You have no maximum hand size.\nIf an effect causes you to discard a card, discard it, but you may put it on top of your library instead of into your graveyard.";

    let mut scenario = GameScenario::new();
    scenario.at_phase(Phase::PreCombatMain);
    for i in 0..3 {
        scenario.add_card_to_hand(SEAT0, &format!("P0 card {i}"));
    }
    scenario.add_card_to_hand(SEAT1, "P1 card");
    scenario
        .add_creature_from_oracle(SEAT0, "Library of Leng", 1, 1, LENG)
        .as_artifact();
    let balance = scenario
        .add_spell_to_hand_from_oracle(SEAT0, "Balance", false, BALANCE_ORACLE)
        .with_mana_cost(ManaCost::zero())
        .id();
    let mut runner = scenario.build();

    runner.cast(balance).resolve();
    if let WaitingFor::DiscardChoice { cards, count, .. } = runner.state().waiting_for.clone() {
        let picked: Vec<ObjectId> = cards.into_iter().take(count).collect();
        runner
            .act(GameAction::SelectCards { cards: picked })
            .expect("selecting Balance's discards must succeed");
    }
    let mut prompts = 0;
    while let WaitingFor::ReplacementChoice { candidates, .. } = runner.state().waiting_for.clone()
    {
        let index = candidates
            .iter()
            .position(|c| c.description == "Decline")
            .expect("Library of Leng must offer Decline");
        runner
            .act(GameAction::ChooseReplacement { index })
            .expect("declining must resume");
        prompts += 1;
    }
    runner.advance_until_stack_empty();
    panic!(
        "MAINREPRO prompts={} p0={} p1={}",
        prompts,
        hand_len(runner.state(), SEAT0),
        hand_len(runner.state(), SEAT1),
    );
}

Class, not card

Any optional replacement that applies to discarding, over any effect that discards a chosen count greater than one. Library of Leng and Balance are the reproduction, not the scope.

Provenance

Found while diagnosing a test fixture on #7494. Reproduced on main at c0da2db9c with no PR content in the tree, so it is not caused by that PR. Filing separately rather than expanding its scope.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions