Skip to content

fix(engine): offer draw-replacement independently per unit of a multi-card draw (CR 121.6b)#5360

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
rykerwilliams:fix-dredge-multidraw-replacement
Jul 8, 2026
Merged

fix(engine): offer draw-replacement independently per unit of a multi-card draw (CR 121.6b)#5360
matthewevans merged 3 commits into
phase-rs:mainfrom
rykerwilliams:fix-dredge-multidraw-replacement

Conversation

@rykerwilliams

Copy link
Copy Markdown
Contributor

Summary

Multi-card draws (Effect::Draw{count: N > 1}) treated the whole count as one atomic replaceable event: accepting a substitute replacement (Dredge, Notion Thief, Hullbreacher, or any other ReplacementEvent::Draw producer) zeroed the entire proposed count, not just the unit it replaced. A count: 2 draw with one dredge-eligible card in the graveyard, if dredged, delivered zero cards instead of one dredged + one normal draw — reproducing a live user report: "drew no cards" when dredging a Stinkweed Imp during Bazaar of Baghdad's "draw two cards" (the unconditional "discard three cards" step still fired correctly afterward, which is what made the bug visible).

CR 121.6b: "If an effect replaces a draw within a sequence of card draws, the replacement effect is completed before resuming the sequence." Each unit of a multi-card draw is an independent event and must independently offer replacement.

Design

Adds resume_multi_draw (game/effects/draw.rs), looping one unit at a time through the existing draw_through_replacement/apply_draw_after_replacement primitives instead of proposing the whole count as one event. A unit that pauses on its own replacement choice stashes a new PendingMultiDraw{player, remaining, accumulated} continuation, drained by handle_replacement_choice's Draw arm using the same re-pause-composes-arbitrarily-many-times contract already proven by the zone-move batch-delivery machinery (library_placement_survives_two_sequential_parks).

CR 609.3: apply_draw_after_replacement now returns the per-unit actually-drawn count instead of writing state.last_effect_count itself — resume_multi_draw accumulates the true total across every unit of the original instruction and commits it exactly once, so a chained "discard that many" sees the real total, not just the last unit's count.

CR 800.4a: PendingMultiDraw is added to replacement::abandon_post_replacement_continuation (the single authority for tearing down a departing player's in-flight continuations) rather than hand-listed in elimination.rs directly — that function exists specifically because a prior bug (issue #4886) was caused by hand-listing fields at the call site instead of routing through one authority.

engine_debug.rs's DebugAction::DrawCards routes through the same primitive for parity with the real draw pipeline.

Explicitly out of scope: Connive's own multi-count draw path (Connive N, N > 1). connive.rs documents a previously-fixed collision between a shared generic mid-draw continuation and its own draw-then-connive ordering requirements — reusing pending_multi_draw at the same resume site risked reintroducing that exact failure mode. Filed as a separate follow-up rather than bundled in here.

Test plan

  • multi_draw_dredges_one_of_two_units_other_draws_normally — drives the real GameAction::ChooseReplacement dispatch, asserts exactly 1 card drawn (not 0, not 2) and last_effect_count == 1 (the true total, not the naive per-unit count)
  • multi_draw_decline_dredge_unit_one_still_draws_unit_two_normally — declining unit 1 still offers the same dredge for unit 2
  • Elimination-cleanup: departing player's pending_multi_draw cleared; surviving player's preserved (extends existing elimination.rs tests)
  • Full engine test suite re-run clean: 15698 passed, 0 failed, 0 regressions
  • Clippy clean (-D warnings)
  • nom-combinator gate passes (no parser files touched)

🤖 Generated with Claude Code

https://claude.ai/code/session_01DMa6DrxXyFHBdGxz3uLgvM

…-card draw (CR 121.6b)

Multi-card draws (Effect::Draw{count: N > 1}) treated the whole count as one
atomic replaceable event: accepting a substitute replacement (Dredge, Notion
Thief, Hullbreacher, or any other ReplacementEvent::Draw producer) zeroed the
ENTIRE proposed count, not just the unit it replaced. A count:2 draw with one
dredge-eligible card in the graveyard, if dredged, delivered zero cards
instead of one dredged + one normal draw -- exactly reproducing a live bug
report: "drew no cards" when dredging during a two-card draw, then the
unconditional discard step still fired as normal.

CR 121.6b: "If an effect replaces a draw within a sequence of card draws, the
replacement effect is completed before resuming the sequence." Each unit of a
multi-card draw is an independent event and must be independently offered
replacement.

Adds resume_multi_draw (game/effects/draw.rs), looping one unit at a time
through the existing draw_through_replacement/apply_draw_after_replacement
primitives instead of proposing the whole count as one event. A unit that
pauses on its own replacement choice stashes the new PendingMultiDraw{player,
remaining, accumulated} continuation (types/game_state.rs), drained by
handle_replacement_choice's Draw arm (engine_replacement.rs) using the same
re-pause-composes-arbitrarily-many-times contract already proven by the
zone-move batch-delivery machinery (zone_pipeline.rs's
library_placement_survives_two_sequential_parks).

CR 609.3: apply_draw_after_replacement now returns the per-unit actually-
drawn count instead of writing state.last_effect_count itself --
resume_multi_draw accumulates the true total across every unit of the
original instruction and commits it exactly once, so a chained "discard that
many" sees the real total (e.g. 1, for a 2-unit draw where one unit was
dredged for 0), not just the last unit's count.

CR 800.4a: PendingMultiDraw is added to replacement::abandon_post_replacement_
continuation (the single authority for tearing down a departing player's
in-flight continuations, per its own documented history of a prior bug
caused by hand-listing fields at the call site instead) rather than
elimination.rs directly -- single-player-scoped, safe to null outright,
unlike the deliberately-preserved multi-player queue fields nearby.

engine_debug.rs's DebugAction::DrawCards routes through the same
resume_multi_draw primitive for parity with the real draw pipeline.

Connive's own multi-count draw path (Connive N, N > 1) is explicitly NOT
touched by this fix -- it has a documented history (connive.rs) of a
previously-fixed collision between a shared generic mid-draw continuation
and its own draw-then-connive ordering requirements, and reusing the new
pending_multi_draw mechanism at the same resume call site risked
reintroducing that exact failure mode. Tracked as a separate follow-up.

Six rounds of adversarial plan review preceded implementation, each finding
a real, previously-hidden issue: incomplete caller enumeration, an
unaddressed count-doubling replacement class (Teferi's Ageless Insight/
Brainsurge, CR 121.2/614.11a), a hand-waved resume signature, last_effect_
count corruption across loop iterations, Connive's collision risk, and the
elimination-cleanup fix's correct location. An off-by-one in the initial
remaining-count bookkeeping was caught by the first test run and fixed
before commit.

Tests: two new production-path regression tests (dredge one-of-two units +
decline-still-draws-normally) driving the real GameAction::ChooseReplacement
dispatch, plus two elimination-cleanup assertions (departing player's
pending_multi_draw cleared; surviving player's preserved). Full engine test
suite re-run clean: 15698 passed, 0 failed, 0 regressions. Clippy clean.
rykerwilliams added a commit to rykerwilliams/phase that referenced this pull request Jul 8, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements independent replacement choices for multi-card draws per CR 121.6b, introducing resume_multi_draw to process draws sequentially and track in-progress state via pending_multi_draw. A high-severity issue was identified where cards drawn by a paused unit after its replacement choice is resolved are omitted from the accumulated total, leading to an incorrect state.last_effect_count when the sequence resumes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

// Mirrors the convention used by `change_zone.rs`, `sacrifice.rs`, etc.
let drawn_count = cards_to_draw.len() as i32;
state.last_effect_count = Some(drawn_count);
let drawn_count = cards_to_draw.len() as u32;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] Drawn cards from paused multi-draw units are omitted from the accumulated total. Evidence: crates/engine/src/game/effects/draw.rs:315.
Why it matters: When a multi-card draw pauses for a replacement choice and is then resumed, any cards actually drawn by the paused unit are completely omitted from state.last_effect_count because apply_draw_after_replacement ignores the accumulated total and the resume path passes the stale pending.accumulated count.
Suggested fix: Update state.pending_multi_draw's accumulated count inside apply_draw_after_replacement so it is correctly preserved when resuming.

    let drawn_count = cards_to_draw.len() as u32;
    if let Some(pending) = &mut state.pending_multi_draw {
        pending.accumulated += drawn_count;
    }

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

[MED] The paused draw unit is omitted from the resumed multi-draw total. Evidence: crates/engine/src/game/engine_replacement.rs:357 calls apply_draw_after_replacement(state, draw, events) but ignores the returned drawn count, then crates/engine/src/game/engine_replacement.rs:665 resumes the tail with pending.accumulated unchanged. resume_multi_draw only adds counts for units it applies itself (crates/engine/src/game/effects/draw.rs:192-199), so a declined/replacement-resolved paused unit that actually draws cards is not included before state.last_effect_count is stamped at crates/engine/src/game/effects/draw.rs:210. Why it matters: chained effects that read EventContextAmount/last_effect_count after a multi-card draw can see a lower total whenever one unit paused on a replacement choice and then drew normally. Suggested fix: capture the u32 returned by the ProposedEvent::Draw resume arm and add it to pending.accumulated before calling resume_multi_draw; add a regression where unit 1 declines Dredge, unit 2 draws normally, and the final last_effect_count includes both drawn units.

This confirms Gemini's current-head concern. I did not approve or enqueue. Confidence: high. Contradicting evidence would be another path that adds the resumed unit's apply_draw_after_replacement return value into PendingMultiDraw.accumulated before the tail resumes; I did not find one on this head.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

…draw total

matthewevans/gemini-code-assist review on phase-rs#5360: handle_replacement_choice's
Draw arm resolves the ONE paused unit directly via apply_draw_after_replacement
and discarded its returned count -- that unit's contribution never reached
resume_multi_draw's accumulator (which only sums units IT executes without
pausing), so state.last_effect_count silently undercounted whenever a paused
unit went on to draw real cards (e.g. declining a Dredge offer).

Fold the just-resolved unit's actually-drawn count into the stashed
PendingMultiDraw.accumulated right where it's produced, before the drain
below reads it to resume the remaining units.

Extends the existing decline regression to also decline unit 2's offer and
assert last_effect_count == 2 (both units' real draws), the exact fixture
the review suggested. Full engine suite re-run clean: 15698 passed, 0
failed, 0 regressions.
@rykerwilliams

Copy link
Copy Markdown
Contributor Author

@matthewevans Confirmed, great catch — verified the exact mechanism before fixing: `handle_replacement_choice`'s `Draw` arm resolves the paused unit directly via `apply_draw_after_replacement` and was discarding its returned count, so it never reached `resume_multi_draw`'s accumulator when the tail resumed.

Fixed in 8c7d5e9: fold the just-resolved unit's drawn count into `PendingMultiDraw.accumulated` right where `apply_draw_after_replacement` returns it, before the drain below reads it. Extended the existing decline regression to also decline unit 2's offer and assert `last_effect_count == 2` (both units' real draws) — the exact fixture you suggested. Full engine suite re-verified clean (15698 passed).

@matthewevans matthewevans self-assigned this Jul 8, 2026
# Conflicts:
#	crates/engine/src/game/engine_replacement.rs
#	crates/engine/src/types/game_state.rs

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

Reviewed current head 4e49588: prior multi-draw accumulator finding is fixed at the replacement resume seam; CI is green; parse-diff shows no card-parse changes.

@matthewevans matthewevans added the bug Bug fix label Jul 8, 2026
@matthewevans matthewevans added this pull request to the merge queue Jul 8, 2026
@matthewevans matthewevans removed their assignment Jul 8, 2026
Merged via the queue into phase-rs:main with commit dfa2c93 Jul 8, 2026
13 checks passed
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