fix(engine): stamp the draw total so a chained "discard that many" reads it (#6858) - #6955
Conversation
…ads it
"Draw N cards, then discard that many" discarded nothing. Varina, Lich
Queen drew and gained life on attack but never discarded.
`QuantityRef::PreviousEffectAmount { channel: Total }` reads only
`state.last_effect_amount` and has no fallback, but `Effect::Draw` commits
its total to `state.last_effect_count`. Neither extractor that populates
`last_effect_amount` had an `Effect::Draw` arm, so both fell through to
their `0` / `None` default: the discard count resolved to 0 and
`discard.rs` short-circuited to a no-op before the `DiscardChoice` branch
was ever reached.
This is a regression. `ea9de8764b` ("bind discard that many to previous
draw count") added an unconditional parser rewrite retargeting `Discard`'s
`EventContextAmount` to `PreviousEffectAmount`, but was parser-only -- the
engine-side producer arm was never added, so the rewritten reference has
pointed at an unfilled channel ever since. The obligation was already
documented on the `PreviousEffectAmount` type: "Every non-damage producer
(life lost, counters removed, cards drawn) stamps only this channel."
Read the committed total rather than re-summing draw events, exactly as the
neighbouring `RollDie` arm defers to `die_result_this_resolution`, so
replacement effects are respected: a draw replaced by something else
contributes 0, one doubled by a count modifier contributes its
post-replacement count.
The arm returns early instead of falling through the `> 0` filter below it.
A draw that delivered zero cards is a real zero and must stamp `Some(0)` --
otherwise "draw a card for each Island you control, then discard that many"
with no Islands would inherit the amount its preceding chain step left
behind.
Also fixes the existing #3296 regression test, which passed vacuously. Its
helper exited via the `Priority` arm after `advance_until_stack_empty`
without ever dispatching `DecideOptionalEffect`, so the draw never happened
and "net hand size unchanged" held for the wrong reason -- it asserted
nothing about library size or that a discard occurred. Confirmed by running
its original body against base behaviour with the bug fully present: it
passed. It now keeps advancing until the optional draw is genuinely taken,
panics if it never settles, and asserts the library actually shrank.
📝 WalkthroughWalkthrough
ChangesDraw amount propagation
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/engine/src/game/effects/mod.rs (1)
6791-6884: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy liftZero-result stale-stamp bug still latent for every non-Draw, non-RollDie effect arm.
This fix correctly special-cases
Effect::Draw(and reuses the existingEffect::RollDiepattern) toreturnthe rawOption<i32>early, so a real zero result stampsSome(0)instead of collapsing toNone. But every other arm in this same match —DealDamage/DamageAll/DamageEachPlayer(Line 6792),Fight(Line 6803),LoseLife/PayCost(Line 6831),GainLife(Line 6838),RemoveCounter(Line 6845) — still falls through to(amount > 0).then_some(amount)at Line 6884.Per the new regression test's own reasoning (
a_zero_card_draw_stamps_zero_instead_of_inheriting_the_previous_step, which asserts a zero draw "must overwrite the precedingGainLifestamp, not leave it standing"), aNonereturn from this function apparently leavesstate.last_effect_amountuntouched, not zeroed. That means the exact bug class this PR fixes for Draw — a chained "that many" step reading a stale amount from an earlier chain step — is still present for any card whose chain usesGainLife,LoseLife,RemoveCounter,DealDamage, orFightwith a genuine zero outcome. For example, "Remove all -1/-1 counters from it, then discard a card for each counter removed this way" preceded by a nonzeroGainLifestep would still leak the stale life-gain amount when zero counters are removed.Apply the same early-return pattern (stamp
Some(sum)unconditionally instead of filtering by> 0) to the other arms, or confirm the caller already treatsNoneas "no data, coerce to 0" rather than "leave stale value standing" — the two are functionally different and only the latter has this defect.Based on path instructions: CLAUDE.md states "Implement this as a general engine-level effect/quantity-channel fix, not a Varina-specific special case... explicitly handle zero values."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/engine/src/game/effects/mod.rs` around lines 6791 - 6884, Update the amount calculation around the match on `ability.effect` so every quantity-producing arm—damage effects, `Fight`, life changes, and `RemoveCounter`—returns its computed sum as `Some(amount)` even when zero, matching the existing early-return behavior of `Effect::Draw` and `Effect::RollDie`. Prevent the final `(amount > 0).then_some(amount)` path from converting genuine zero results into `None`; preserve the existing handling for effects without a quantity result.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@crates/engine/src/game/effects/mod.rs`:
- Around line 6791-6884: Update the amount calculation around the match on
`ability.effect` so every quantity-producing arm—damage effects, `Fight`, life
changes, and `RemoveCounter`—returns its computed sum as `Some(amount)` even
when zero, matching the existing early-return behavior of `Effect::Draw` and
`Effect::RollDie`. Prevent the final `(amount > 0).then_some(amount)` path from
converting genuine zero results into `None`; preserve the existing handling for
effects without a quantity result.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 22f28fd5-9164-4d5e-8d30-05f2976507ec
📒 Files selected for processing (4)
crates/engine/src/game/effects/mod.rscrates/engine/tests/integration/issue_3296_hordewing_skaab_discard.rscrates/engine/tests/integration/issue_6858_draw_that_many_discard.rscrates/engine/tests/integration/main.rs
|
Good catch on the class, and thank you for naming the discriminator rather than just asserting the bug — "confirm the caller already treats The caller leaves the stale value standing. Both call sites are: if let Some(amount) = previous_effect_amount_from_events(state, ability, parent_events) {
state.last_effect_amount = Some(amount);
…
}A But the fix you propose would introduce the mirror bug, and that's why it isn't in this PR. The two cases are not symmetric:
A correct fix has to distinguish "no matching events" from "matched events summing to zero" — count the matches rather than filter on the total — and it has to answer a rules question per arm before it can be written: does damage fully prevented count as damage dealt for "that much" purposes (CR 615 vs CR 120.6)? Does So: real, in the same function, and deliberately not bundled. Filed as a follow-up with this analysis rather than widened into a fix that already has verified red/green evidence for a different, self-contained defect. The |
|
Generated for head Parse changes introduced by this PR✓ No card-parse changes detected. |
`effects/mod.rs:8949 => :8970`, +21 matching that commit's insertion count exactly. Producer re-read at the new coordinate: sha256-identical to the same line pre-merge. The other four pins did not move, which locates the insertion below two of them and is itself evidence the SET is unchanged. Records the row's CI-only failure mode: CI checks out `refs/pull/<n>/merge`, so an upstream insertion above a producer reds this row in CI while it stays green locally until the branch merges that upstream. Assisted-by: ClaudeCode:claude-opus-5
Fixes #6858.
The bug
"Draw N cards, then discard that many" discarded nothing. Varina, Lich Queen drew and gained life on attack but never discarded.
A producer/consumer channel mismatch:
QuantityRef::PreviousEffectAmount { channel: Total }reads onlystate.last_effect_amount, with no fallback. (Its siblingEventContextAmountdoes cascade through.or(state.last_effect_count).)Effect::Drawcommits its total tostate.last_effect_count.last_effect_amount—previous_effect_amount_from_eventsorprevious_effect_counts_by_player_from_events— had anEffect::Drawarm. Both fell through to their_ => 0/_ => return Nonedefault.So
last_effect_amountstayedNone, the discard count resolved to0, anddiscard.rsshort-circuited to a no-op before theWaitingFor::DiscardChoicebranch was ever reached.This is a regression, and the contract was already written down
ea9de8764b("fix(parser): bind discard that many to previous draw count (issue #3296) (#3541)") added an unconditional parser rewrite retargetingDiscard'sEventContextAmounttoPreviousEffectAmount. It was parser-only — the engine-side producer arm was never added, so the rewritten reference has pointed at an unfilled channel ever since.The obligation was already documented on the
PreviousEffectAmounttype itself: "Every non-damage producer (life lost, counters removed, cards drawn) stamps only this channel." Draw was a documented producer obligation that was never implemented.The fix
One arm, reading the committed total rather than re-summing draw events — exactly as the neighbouring
RollDiearm defers todie_result_this_resolution. That matters for correctness, not just tidiness:resume_draw_sequenceis the single authority for how many cards an instruction actually delivered, so replacement effects are respected. A draw replaced by something else contributes 0; one doubled by a count modifier contributes its post-replacement count.The arm returns early instead of falling through the
> 0filter below it. A draw that delivered zero cards is a real zero and must stampSome(0)— otherwise "draw a card for each Island you control, then discard that many" controlling no Islands would inherit whatever amount its preceding chain step left behind.CR 121.2 + CR 121.2a + CR 608.2c, all grep-verified.
The existing regression test was passing vacuously
issue_3296_hordewing_skaab_discard.rsassertedhand_after == hand_beforeand passed on main — which superficially contradicts this whole diagnosis. It was vacuous: its helper exited via thePriorityarm afteradvance_until_stack_emptywithout ever dispatchingDecideOptionalEffect, so the draw never happened and "net hand size unchanged" held for the wrong reason. It asserted nothing about library size and nothing about a discard occurring.Confirmed by observation, not inference: the test's verbatim original body was run against base engine behaviour with the bug fully present, and it passed.
It now keeps advancing until the optional draw is genuinely taken, panics if it never settles rather than silently falling through, and asserts the library actually shrank.
Scope — the class is 6 cards, and 4 more are broken by something else
9 cards carry
Discard { count: PreviousEffectAmount }.Fixed here (6, draw-preceded): Varina, Lich Queen · Hordewing Skaab · Horrid Shadowspinner · Laquatus's Creativity · Last Stand · Transcendent Archaic.
Not fixed, different defect (4): Dreamstealer, Needle Specter, Jagged Poppet, Marina Vendrell's Grimoire. Their
Discardis a top-level trigger effect, andlast_effect_amountis cleared at depth 0 and only ever written by the sub-ability postlude — so it is alwaysNoneregardless of producer. Neither this fix nor the alternative reaches them. Filed separately rather than bundled.Verification
issue_6858_draw_that_many_discard.rscovers the channel contract as a building block (draw_stamps_the_total_channel_a_chained_that_many_discard_reads), the reported card (varina_attack_trigger_discards_as_many_as_it_drew), and the zero-draw edge case the fix's early return exists for (a_zero_card_draw_stamps_zero_instead_of_inheriting_the_previous_step).-p phase-enginesuite: 22,803 passed / 0 failed. Clippy-D warningsclean.Note on the alternative fix
Adding
.or(state.last_effect_count)at the consumer was considered and rejected: it has a much wider blast radius across every non-damage consumer, whereas the producer arm restores a contract the type documentation already claimed held.Summary by CodeRabbit
Bug Fixes
Tests