Skip to content

fix(parser): scope count-form "would draw N or more" draw replacements (Alms Collector #5678) - #5867

Open
Yurii214 wants to merge 4 commits into
phase-rs:mainfrom
Yurii214:fix/5678-draw-count-form-antecedent
Open

fix(parser): scope count-form "would draw N or more" draw replacements (Alms Collector #5678)#5867
Yurii214 wants to merge 4 commits into
phase-rs:mainfrom
Yurii214:fix/5678-draw-count-form-antecedent

Conversation

@Yurii214

@Yurii214 Yurii214 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Closes #5678.

Problem

parse_replacement_line's draw-antecedent alt matched only "would draw a card" and a hardcoded "would draw one or more cards". Alms Collector's antecedent is the count-form "would draw two or more cards", so it matched neither and the line produced no replacement at all — the card silently did nothing.

Fix

Two parts, both at the parser seam (crates/engine/src/parser/oracle_replacement.rs):

  1. Recognize the count-form antecedent as a class. Replace the hardcoded "one or more cards" tag with "would draw " + parse_number + " or more cards", so every <N> or more threshold (one, two, three, …) is recognized — build-for-the-class rather than a per-card tag. parse_number already covers digit and word forms.

  2. Derive draw_scope from the substitute, not the antecedent. This is the subtle part, and it corrects the issue's stated expectation. The issue predicted InstructionCount, but that contradicts DrawReplacementScope's own doc and scripts/draw_replacement_census.py::classify_scope: InstructionCount is reserved for count-modifier substitutes that read the replaced instruction's own count ("draw that many cards plus one instead" — Quantum Riddler, a QuantityRef::EventContextAmount; the only such card). Alms Collector's substitute is a fixed substitution ("instead you and that player each draw a card") — the Notion Thief / Hullbreacher class the enum doc explicitly scopes as IndividualDraw (CR 121.6b: cards are drawn one at a time).

    So the scope is finalized after the execute chain is parsed. Both antecedent forms seed the IndividualDraw default; execute_draw_reads_replaced_count then inspects the execute's top-level Draw effect — the exact surface classify_scope reads (execute["effect"]) — for a count that references EventContextAmount, and promotes to InstructionCount only then. Emitting the scope from the same signal the census reads keeps producer and cross-check in agreement by construction: Alms Collector → IndividualDraw, Quantum Riddler → InstructionCount, and no existing card's scope changes.

Why the corpus baseline moves

scripts/draw-replacement-corpus.tsv gains exactly one row (alms collector … IndividualDraw) now that the card produces a Draw replacement. Re-frozen with scripts/draw_replacement_census.py --corpus --write in this commit; --corpus --check is green (51 rows), and the diff is that single added row — no existing row moved.

Verification

  • New parser unit test count_form_draw_antecedent_is_recognized_and_scope_follows_the_substitute drives the real entry (parse_replacement_line) and asserts scope follows the substitute: Alms Collector + a fixed "three or more" → IndividualDraw; a "that many … plus one" count-modifier → InstructionCount; singular "a card" → IndividualDraw.
  • cargo test -p engine --lib (the new test) — green.
  • draw_replacement_census.py --corpus --check — green after re-freeze (against a freshly regenerated card-data.json).
  • cargo clippy -p engine --lib -- -D warnings — clean.
  • CR 121.2a / CR 121.6b verified against docs/MagicCompRules.txt.

Model: claude-opus-4-8[1m]

Summary by CodeRabbit

  • New Features

    • Added support for “draw N or more” replacement effects, including proper threshold handling for multi-card draws.
    • Improved rule interpretation so “draw N or more” conditions are lowered with the correct quantity requirement.
    • Distinguishes replacement checks that occur before a multi-card draw is split versus checks made per individual card.
  • Bug Fixes

    • Fixed threshold-based draw replacements being skipped or misapplied after multi-card draws are split.
    • Ensured fully substituted draw instructions short-circuit correctly without incorrect follow-up evaluation.
  • Tests

    • Added coverage for threshold gating and end-to-end draw-sequence behavior.

@Yurii214
Yurii214 requested a review from matthewevans as a code owner July 15, 2026 15:53

@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 improves the draw replacement parser to recognize count-form antecedents (e.g., "would draw or more cards") and dynamically determine the replacement scope (IndividualDraw vs. InstructionCount) based on the substitute's behavior rather than the antecedent's grammatical number. Feedback focuses on ensuring that the newly added comments strictly adhere to Rule R6's formatting requirements for CR annotations to prevent breaking automated regex verification.

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.

Comment thread crates/engine/src/parser/oracle_replacement.rs Outdated
Comment thread crates/engine/src/parser/oracle_replacement.rs Outdated
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 1 card(s), 2 signature(s) (baseline: main 6ae8737cdab0)

🟢 Added (1 signature)

  • 1 card · ➕ replacement/Draw · added: Draw (condition=OnlyIfQuantity { lhs: Ref { qty: EventContextAmount }, comparator: GE, rhs: Fixed { value: 2 }, active_player_req: None }, draw scope=Instructi…
    • Affected (first 3): Alms Collector

🔴 Removed (1 signature)

  • 1 card · ➖ ability/replacement_structure · removed: replacement_structure
    • Affected (first 3): Alms Collector

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

Verdict: changes requested — the parser recognizes the count form, but the resulting definition cannot enforce its N or more threshold at runtime.

🔴 Blocker

crates/engine/src/parser/oracle_replacement.rs:426-443 parses the numeric antecedent but retains only IndividualDraw; :526-542 then derives the scope solely from the substitute. No field on ReplacementDefinition carries the parsed N, and draw_scope only selects instruction versus individual-draw matching. Consequently, Alms Collector's two or more condition is discarded: an individual-draw matcher sees one card at a time, while an instruction matcher has no typed >= 2 predicate to enforce. This would either never apply to the intended two-card instruction or apply to the wrong draw class.

The earlier #5678 investigation reached the same implementation boundary: issue comment. CR 121.2a applies the relevant replacement before individual draws; CR 121.6b then governs resuming the individual sequence. The current parser-only test proves AST recognition and chosen scope, not that a pending two-card draw is gated correctly or that a one-card draw is left alone.

Please carry the antecedent through a typed replacement condition (for example, EventContextAmount >= N), teach the census classifier to recognize that typed signal as InstructionCount, and verify the replacement matcher resolves it against the pending instruction count. Add an engine-pipeline regression that proves: opponent draw 1 is unchanged; opponent draw 2 is replaced; and the replacement's two draws occur for the correct players.

🟡 Non-blocking

The fixed-substitute versus count-modifier distinction is real, but it does not eliminate the antecedent threshold. It decides what the substitute does; the typed antecedent still decides whether the instruction is eligible.

✅ Clean

parse_number is the right reusable combinator for the open-ended N or more grammar axis, rather than a two or more special case.

Recommendation: rework this through the draw-replacement model/runtime path, then request re-review on the new head.

@Yurii214

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — the blocker is right: the parsed N isn't carried, so as-is the replacement doesn't gate on "two or more." Before reworking, I traced the draw-replacement runtime to wire the threshold through OnlyIfQuantity { lhs: EventContextAmount, comparator: GE, rhs: N } (reusing the existing condition rather than adding a variant), and I wanted to confirm the intended approach, because it looks like draw_scope isn't yet load-bearing at runtime:

  • draw_scope has no behavioral read in game/. Grep finds it only at game/coverage.rs:4347 (display) and validate_draw_scope / the consult-seam debug_assert! — no matcher branches on InstructionCount vs IndividualDraw.
  • draw_matcher is scope-agnostic (game/replacement.rs:1941): matches!(event, ProposedEvent::Draw { count, .. } if *count > 0).
  • The pipeline decomposes before offering replacements. start_draw_sequenceresume_draw_sequence draws one card at a time, calling draw_through_replacement_with_applied(state, player, 1, …) (game/effects/draw.rs), so every draw replacement is offered against ProposedEvent::Draw { count: 1 }.
  • The condition path can't see the count. evaluate_replacement_condition's OnlyIfQuantity arm resolves through replacement_condition_quantity_ctx, whose QuantityContext carries no event amount, so EventContextAmount doesn't resolve to the pending draw's count there (unlike the execute path's resolve_event_replacement_quantity).

So an EventContextAmount >= 2 gate would see 1 per unit and never fire — enforcing the threshold seems to require building the CR 121.2a instruction stage that draw_scope anticipates: offer InstructionCount defs against the whole instruction before per-card decomposition, make matching scope-aware, and resolve EventContextAmount for the gate. Since that's a central change to the draw pipeline (and Quantum Riddler's InstructionCount is currently unimplemented at the matcher too), I wanted to check the intended design before touching it.

I'm happy to build it end-to-end: parser (OnlyIfQuantity{EventContextAmount>=N} + InstructionCount), census (classify_scope recognizing the condition-subtree signal), the instruction-stage matcher + condition resolution, and a GameScenario regression (opponent draw 1 unchanged; draw 2 replaced; both substitute draws to the correct players). Before I do — does that match how you'd want the instruction stage wired, or would you prefer a narrower scope (or to take the core-pipeline piece yourselves)? And please point me at any existing scope-aware seam if I've missed one.

@matthewevans

Copy link
Copy Markdown
Member

@Yurii214 yes, we just refactored how draw works in the engine. If you rebase you should be able to leverage the new approach to correctly complete this PR

  • Confirmed blocker: PR 5867 parses “two or more” but discards N. Its draw_scope is not used for runtime matching, and the current sequence reduces every draw instruction to one-card events. So Alms Collector’s “two or more” condition cannot be enforced.
  • The new centralized start_draw_sequence / DrawSequenceFrame flow provides the right instruction-level seam to evaluate the full pending draw count before it decomposes into individual draws.
  • Required rework: retain N as a typed replacement condition; add instruction-stage matching/context for the pending draw count; apply it before individual draws; add end-to-end one-card vs two-card regression tests. The current “infer scope from execute shape” approach conflicts with the new scope contract.

…ments

Recognize the count-form draw antecedent "would draw <N> or more cards"
(parse_number -- build for the class, not a "two or more" special case) and,
for N >= 2, retain N as a typed ReplacementCondition::OnlyIfQuantity over the
event's draw count (EventContextAmount >= N) -- reusing the existing condition,
no new variant -- composed (And) with any as-long-as / while gate. Scope is
InstructionCount. Teach the census classifier that a Draw whose condition
references EventContextAmount is InstructionCount (the instruction-count signal
lives in the antecedent threshold, not just the execute count); re-freeze the
corpus (+1: Alms Collector).

The instruction-stage runtime enforcement (an InstructionCount replace_event
offer at the draw seam, the draw_scope match-gate, and EventContextAmount
threading into condition evaluation) is a separate core-draw change tracked in
the PR discussion.

Refs phase-rs#5678

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Yurii214
Yurii214 force-pushed the fix/5678-draw-count-form-antecedent branch from 4e541d7 to c88f2b6 Compare July 16, 2026 00:37
@Yurii214

Copy link
Copy Markdown
Contributor Author

Rebased onto the new draw engine and pushed the parser + condition + census layer:

  • Parser — the count-form antecedent now captures N via parse_number (build for the class: any <N> or more cards, not a "two or more" special case) and, for N ≥ 2, retains it as a typed ReplacementCondition::OnlyIfQuantity { lhs: EventContextAmount, comparator: GE, rhs: N } (reusing the existing condition — no new variant), composed (And) with any as-long-as/while gate. Scope is InstructionCount; opponent player-scope and the "you + that player each draw" substitute already lower correctly. Dropped the old "infer scope from execute shape" approach per your note.
  • Censusclassify_scope now treats a Draw definition whose condition references EventContextAmount as InstructionCount (the instruction-count signal lives in the antecedent threshold, not just the execute's count); corpus re-frozen. Quantum Riddler is unchanged (still InstructionCount via its execute).
  • Parser unit test asserts Alms Collector → Draw / InstructionCount / valid_player: Opponent / OnlyIfQuantity(EventContextAmount ≥ 2).

What's left is the instruction-stage runtime, and I'd like to match your intended design before touching the core seam. Tracing the new flow: start_draw_sequence_with_origin pushes the frame with the full count then calls resume_draw_sequence — the seam is right there — but three pieces are still unwired: (1) no instruction-level replace_event offer against Draw { count: N } exists yet (every draw is still offered per-unit at count: 1); (2) draw_scope still doesn't gate matching, so enforcing it newly activates Quantum Riddler's InstructionCount too; (3) EventContextAmount isn't threaded into condition evaluation (replacement_condition_quantity_ctx's QuantityContext carries no event amount), so OnlyIfQuantity{EventContextAmount ≥ 2} currently resolves against 0.

Since you just refactored this and it's high blast radius, how would you prefer the instruction offer wired — e.g. a DrawStage { Instruction, Unit } discriminator on ProposedEvent::Draw gated against draw_scope, or a scope post-filter of the candidate list at the seam? I'm happy to build the whole runtime to your contract (offer + scope gate + QuantityContext.event_amount threading + a one-card-vs-two-card GameScenario regression), or if you'd rather own the core matcher hook I'll finish the condition-threading and tests on top. Whichever you prefer.

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

Verdict: changes requested — the parser/census work now models the threshold correctly, but it advertises a supported instruction-stage replacement before the runtime has the required instruction-stage authority.

🔴 Blocker

crates/engine/src/parser/oracle_replacement.rs:424-460 lowers Alms Collector to InstructionCount with OnlyIfQuantity(EventContextAmount >= N), while crates/engine/src/game/effects/draw.rs:248-307 still decomposes the frame and consults only ProposedEvent::Draw { count: 1 }. The full parse-diff consequently reports Alms Collector as added support, but no candidate can see its count-two instruction and the condition cannot be true in the per-unit consultation. This is coverage-incorrect rather than a parser-only intermediate.

The intended runtime shape is the Plan 03 draw authority: keep DrawReplacementScope as the definition-side classifier, add a separate explicit event-side DrawEventStage (instruction-count versus individual draw), and consult scope-compatible definitions at the matching stage. The instruction consultation must happen once against the full count before units are created; each surviving unit then gets its own individual-draw consultation. Thread that stage event's amount into condition quantity resolution, rather than adding a post-filter or inferring scope from the execute body. This is the design needed for both Alms Collector and Quantum Riddler and preserves CR 121.2a's ordering.

03-draw-and-zone-authority.md is also clear that this belongs in the centralized, pause-safe draw sequence and should follow its Plan-02 preflight, not as a parser-only acceptance change. Please either complete that scoped runtime work with one-card/two-card end-to-end GameScenario coverage (including the correct replacement draws), or keep Alms Collector honestly unsupported until the Plan-03 implementation lands.

✅ Clean

The new antecedent lowering is at the right parser seam: parse_number plus a typed OnlyIfQuantity condition represents the full N or more class without a card-specific branch.

Recommendation: retain the typed parser direction, but land it only with the planned staged runtime authority and discriminating runtime coverage; do not add a generic candidate post-filter.

@matthewevans matthewevans self-assigned this Jul 16, 2026
@matthewevans matthewevans added the bug Bug fix label Jul 16, 2026
@matthewevans matthewevans removed their assignment Jul 16, 2026
@matthewevans

Copy link
Copy Markdown
Member

Clarification to my prior review: you do not have access to the internal remediation plan I referenced, and I am not asking you to implement a hidden core-pipeline design.

Your diagnosis is correct: the current engine has no public, complete instruction-stage replacement contract, so this PR cannot safely finish the runtime portion from the available contributor context alone. Please do not add a speculative candidate post-filter or a new ad-hoc draw discriminator. We will own the centralized staged draw work and publish the relevant contract before requesting contributor changes in that area.

For this PR, the actionable state is simply that parser/census acceptance cannot merge while the runtime is absent, because it would mark Alms Collector supported without enforcing its threshold. We will follow up after the shared runtime lands; no additional implementation is expected from you now.

@matthewevans matthewevans self-assigned this Jul 16, 2026

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

Changes requested — parser coverage claims a runtime capability that the draw pipeline does not implement.

🔴 Blocker

  • crates/engine/src/parser/oracle_replacement.rs:408-580 parses Alms Collector as InstructionCount and records EventContextAmount >= 2; scripts/draw_replacement_census.py:304-326 then freezes that parser classification. But the draw execution authority is still the per-unit sequence driver in crates/engine/src/game/effects/draw.rs:138-221; this PR adds no instruction-stage consult/apply path for DrawReplacementScope::InstructionCount. The new test at oracle_replacement.rs:17076-17120 asserts only the AST/coverage shape, so it cannot prove that an opponent's two-card draw becomes the required one-card-for-each substitute (or that a one-card draw remains unchanged).

Please hold this parser/coverage change until the core draw-replacement contract has a real instruction-stage authority, then land it with registered runtime regressions for 1-card and 2+-card opponent draws.

✅ Clean

  • The antecedent grammar is compositional and the threshold is typed rather than a card-name special case.

Recommendation: request changes — do not mark Alms Collector supported before the instruction-count runtime path exists.

@matthewevans matthewevans removed their assignment Jul 16, 2026
@Yurii214

Copy link
Copy Markdown
Contributor Author

Understood, and thanks for the clear guidance — I'll hold on the runtime and won't add a speculative post-filter or draw discriminator. Glad the threshold modeling (parser + OnlyIfQuantity{EventContextAmount >= N} + the census condition rule) is on the right track.

Happy to keep this PR open so that layer is ready to combine once the shared staged-draw runtime + contract land, or to close it and re-open against the published contract — whichever keeps your queue cleaner. Just let me know. Appreciate the detailed engagement.

@matthewevans

Copy link
Copy Markdown
Member

Held — the shared draw-replacement authority is still absent, so no contributor implementation is requested.

🔴 Blocker

crates/engine/src/parser/oracle_replacement.rs:408-580 parses and classifies the count-form antecedent, but crates/engine/src/game/effects/draw.rs:138-221 still has no instruction-stage consultation for DrawReplacementScope::InstructionCount. The current parse-diff therefore advertises Alms Collector as supported without a runtime path that can distinguish a one-card draw from a two-card instruction.

✅ Clean

Your follow-up correctly avoids a speculative post-filter or ad-hoc draw discriminator.

Recommendation: keep this PR held until the centralized staged draw-replacement contract lands; no further contributor change is expected now.

@matthewevans matthewevans self-assigned this Jul 23, 2026
@matthewevans

Copy link
Copy Markdown
Member

Expiry warning — this PR remains held on c88f2b6cc0c7bfbb06e6e22f9ff677b74534f052. The requested-changes blocker remains open: the shared staged draw-replacement authority is still absent, so this parser/census change cannot merge without incorrectly claiming support for Alms Collector's two-or-more draw threshold.

Unless the requested changes are addressed within 7 days, this PR will be automatically closed to keep the review queue current. A new head or material contributor follow-up will stop that expiry and return the PR to review.

@matthewevans matthewevans removed their assignment Jul 23, 2026
@Yurii214

Copy link
Copy Markdown
Contributor Author

Thanks for the heads-up. Confirming state so the queue stays accurate: per your 2026-07-16 / 07-17 notes this is held for the shared staged draw-replacement authority, and you asked that no contributor implementation be attempted from the available context — so the head is unchanged at c88f2b6 (the parser + census layer for the count-form "draw N or more" threshold, which you confirmed models the threshold correctly).

Happy to keep it open until the shared runtime lands, or to close and reopen a fresh head once the instruction-stage replacement contract is available — whichever keeps your queue cleanest. Otherwise this note should hold off the auto-expiry.

@matthewevans

Copy link
Copy Markdown
Member

Held — confirmation received.

The current head c88f2b6cc0c7bfbb06e6e22f9ff677b74534f052 remains intentionally held because the shared staged draw-replacement authority is still absent. No contributor implementation is requested, and no implementation re-review or approval occurred on this unchanged head.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2aa4271f-279b-49fc-b473-d5162d4cab1f

📥 Commits

Reviewing files that changed from the base of the PR and between 679c3cf and fd86d17.

📒 Files selected for processing (4)
  • crates/engine/src/game/effects/draw.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/src/types/game_state.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/engine/src/types/ability.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/game/effects/draw.rs
  • crates/engine/src/game/replacement.rs

📝 Walkthrough

Walkthrough

Changes

Instruction-count draw replacements

Layer / File(s) Summary
Draw consultation scope state
crates/engine/src/types/ability.rs, crates/engine/src/types/game_state.rs
Adds DrawConsultScope and stores its transient value in GameState, defaulting to the individual-card seam and omitting that default during serialization.
Count-form replacement parsing
crates/engine/src/parser/oracle_replacement.rs, scripts/draw_replacement_census.py
Parses numeric “draw N or more” antecedents into instruction-count replacements with EventContextAmount thresholds, and updates census classification for condition-based counts.
Pre-split draw replacement resolution
crates/engine/src/game/replacement.rs, crates/engine/src/game/effects/draw.rs
Consults instruction-count replacements before splitting draws into cards, carries updated replacement state into the sequence, and returns immediately when the instruction is replaced. Tests cover one-card and two-card threshold behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DrawSequence
  participant ReplacementResolver
  participant GameState
  DrawSequence->>ReplacementResolver: replace_draw_instruction(count, applied)
  ReplacementResolver->>GameState: set Instruction consultation scope
  ReplacementResolver-->>DrawSequence: Proceed with updated count/applied or Replaced
  DrawSequence->>DrawSequence: split surviving count into individual draws
Loading

Suggested labels: quality

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly names the count-form draw-replacement fix and the Alms Collector issue.
Linked Issues check ✅ Passed The changes parse “would draw N or more,” preserve the threshold, add instruction-stage runtime handling, and update tests/census for #5678.
Out of Scope Changes check ✅ Passed All changes support the draw-threshold fix: parser, runtime consult, state plumbing, census, and tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@matthewevans

Copy link
Copy Markdown
Member

Any pending work mentioned for draw/replacement should now be implemented for this PR to use.

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

Request changes — current head b4e24382.

[HIGH] Instruction-count replacements still never reach a runtime authority. oracle_replacement.rs now lowers the count form to DrawReplacementScope::InstructionCount plus OnlyIfQuantity { EventContextAmount >= N }, but the current draw path in game/effects/draw.rs only invokes replacement from resume_draw_sequence through per-card ProposedEvent::Draw { count: 1 }. A source scan of the current head finds InstructionCount only in the parser/type declarations and tests, not in a runtime resolver. Consequently an Alms Collector-style "would draw two or more cards" replacement cannot apply to a two-card instruction.

The branch history contains the needed pattern in ab673bc104 (replace_draw_instruction before the per-card split), but that authority is not present on this head after the merge. Please restore/adapt that instruction-stage consult before splitting the draw sequence, and add a production-path regression showing draw-one is unaffected while draw-two is replaced.

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

Request changes — current head b0cea689.

The intervening merge changes only dependency/version files; it does not restore the missing instruction-stage draw replacement authority. InstructionCount remains parser/type-only on this head while the production draw path applies replacements only to count: 1 per-card events. The prior requested runtime-seam fix and draw-one/draw-two production-path regression are still required.

@matthewevans

Copy link
Copy Markdown
Member

The current head remains blocked by the requested instruction-stage draw-replacement fix and runtime regression. Please update the branch before requesting another review.

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

Actionable comments posted: 4

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

Inline comments:
In `@crates/engine/src/game/replacement.rs`:
- Around line 8442-8453: Restore state.draw_consult_scope to prev_scope
immediately after replace_event and before calling
apply_pending_post_replacement_effect. Keep the post-replacement drain
conditional unchanged, ensuring substitute draws execute under the normal scope
and can use the IndividualDraw seam.

In `@crates/engine/src/parser/oracle_replacement.rs`:
- Around line 597-619: Preserve threshold_n for every count-form branch by
composing its OnlyIfQuantity condition into def.condition before any as-long-as
or draw-skip branch returns. Ensure all early-return paths retain the N-or-more
gate while preserving existing condition composition. Add a regression test
confirming a gated “draw two or more cards” replacement does not apply to a
one-card draw.
- Around line 604-612: Update the threshold construction in the threshold_n
handling to convert n with i32::try_from instead of a wrapping cast. If
conversion overflows, fail closed by skipping or rejecting the replacement
condition rather than creating a negative rhs value.

In `@crates/engine/src/types/ability.rs`:
- Around line 19950-19954: Update the documentation for DrawReplacementScope to
remove the CR 121.6b reference from this consult-scope description, retaining
only CR 121.2a and its explanation of the pre-split whole-instruction consult.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 225c9624-979a-45ef-86e4-d81b157327b1

📥 Commits

Reviewing files that changed from the base of the PR and between 6ae8737 and 679c3cf.

⛔ Files ignored due to path filters (1)
  • scripts/draw-replacement-corpus.tsv is excluded by !**/*.tsv
📒 Files selected for processing (6)
  • crates/engine/src/game/effects/draw.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/parser/oracle_replacement.rs
  • crates/engine/src/types/ability.rs
  • crates/engine/src/types/game_state.rs
  • scripts/draw_replacement_census.py

Comment on lines +8442 to +8453
let result = replace_event(state, instruction, events);
// CR 614.6 + CR 121.6: a full substitution (Alms Collector: "instead you and
// that player each draw a card") is pre-zeroed by `apply_single_replacement`
// and its substitute stashed as a post-replacement continuation. Drain it in
// the same resolution step, mirroring `draw_through_replacement`'s Execute
// arm, so the substitute runs before the (now zero-count) instruction below.
if !matches!(result, ReplacementResult::NeedsChoice(_)) && state.has_post_replacement_drain() {
let _ = crate::game::engine_replacement::apply_pending_post_replacement_effect(
state, None, None, None, events,
);
}
state.draw_consult_scope = prev_scope;

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Trace whether the post-replacement drain can run a Draw substitute synchronously.
rg -nP --type=rust -C4 'fn apply_pending_post_replacement_effect' crates/engine/src
# Look for draw dispatch reachable from the drained continuation.
rg -nP --type=rust -C3 '\bstart_draw_sequence(_with_[a-z_]+)?\s*\(' crates/engine/src/game/engine_replacement.rs

Repository: phase-rs/phase

Length of output: 874


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the post-replacement drain implementation and the draw replacement seam.
sed -n '1981,2065p' crates/engine/src/game/engine_replacement.rs
printf '\n---\n'
sed -n '8418,8460p' crates/engine/src/game/replacement.rs
printf '\n---\n'
rg -n --type=rust 'draw_consult_scope|DrawConsultScope::|IndividualDraw|Instruction' crates/engine/src/game -C 3

Repository: phase-rs/phase

Length of output: 36244


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the drain body and any helper it calls that can schedule draw events.
sed -n '1981,2045p' crates/engine/src/game/engine_replacement.rs
printf '\n---\n'
rg -n --type=rust 'start_draw_sequence|draw_through_replacement|apply_single_replacement|apply_pending_post_replacement_effect' crates/engine/src/game -C 4

Repository: phase-rs/phase

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Print the relevant function body.
awk 'NR>=1981 && NR<=2065 {print NR ":" $0}' crates/engine/src/game/engine_replacement.rs
printf '\n---\n'
awk 'NR>=8438 && NR<=8460 {print NR ":" $0}' crates/engine/src/game/replacement.rs
printf '\n---\n'
rg -n --type=rust -C 4 'IndividualDraw|DrawConsultScope::Instruction|DrawConsultScope::Individual' crates/engine/src/game

Repository: phase-rs/phase

Length of output: 28948


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the consult-scope branch and the drain helper implementation.
rg -n --type=rust -C 5 'draw_consult_scope|DrawConsultScope::Instruction|IndividualDraw|return false' crates/engine/src/game
printf '\n---\n'
sed -n '1981,2065p' crates/engine/src/game/engine_replacement.rs

Repository: phase-rs/phase

Length of output: 50371


Restore draw_consult_scope before draining the substitute. apply_pending_post_replacement_effect runs the continuation immediately, and any draw it issues while state.draw_consult_scope == DrawConsultScope::Instruction will skip IndividualDraw shields because that seam only accepts InstructionCount there. Move the scope reset above the drain so the substitute’s per-card draws still see the normal IndividualDraw seam.

🤖 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/replacement.rs` around lines 8442 - 8453, Restore
state.draw_consult_scope to prev_scope immediately after replace_event and
before calling apply_pending_post_replacement_effect. Keep the post-replacement
drain conditional unchanged, ensuring substitute draws execute under the normal
scope and can use the IndividualDraw seam.

Comment on lines +597 to +619
// CR 121.2a: a "draw N or more cards" antecedent (N >= 2) gates the
// replacement on the pending draw *instruction* being for at least N
// cards. Carry N as a typed `OnlyIfQuantity` over the event's draw count
// (`EventContextAmount`), evaluated at the instruction stage before the
// draw decomposes into individual card draws — composed (And) with any
// as-long-as / while / except-first gate already set. Alms Collector:
// "If an opponent would draw two or more cards, ...".
if let Some(n) = threshold_n {
let threshold = ReplacementCondition::OnlyIfQuantity {
lhs: QuantityExpr::Ref {
qty: QuantityRef::EventContextAmount,
},
comparator: Comparator::GE,
rhs: QuantityExpr::Fixed { value: n as i32 },
active_player_req: None,
};
def.condition = Some(match def.condition.take() {
Some(existing) => ReplacementCondition::And {
conditions: vec![existing, threshold],
},
None => threshold,
});
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve N on every count-form branch.

Line 568 returns after applying an as long as condition, and the draw-skip branches return even earlier, so they bypass Lines 604-618. A gated would draw two or more cards replacement can therefore lose its threshold and apply to a one-card draw. Compose the threshold through the shared condition path before any branch returns; add a gated count-form regression test.

Also applies to: 18804-18838

🤖 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/parser/oracle_replacement.rs` around lines 597 - 619,
Preserve threshold_n for every count-form branch by composing its OnlyIfQuantity
condition into def.condition before any as-long-as or draw-skip branch returns.
Ensure all early-return paths retain the N-or-more gate while preserving
existing condition composition. Add a regression test confirming a gated “draw
two or more cards” replacement does not apply to a one-card draw.

Source: Path instructions

Comment on lines +604 to +612
if let Some(n) = threshold_n {
let threshold = ReplacementCondition::OnlyIfQuantity {
lhs: QuantityExpr::Ref {
qty: QuantityRef::EventContextAmount,
},
comparator: Comparator::GE,
rhs: QuantityExpr::Fixed { value: n as i32 },
active_player_req: None,
};

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant parser section.
sed -n '560,640p' crates/engine/src/parser/oracle_replacement.rs

echo
echo '--- parse_number references ---'
rg -n "parse_number|threshold_n|ReplacementCondition::OnlyIfQuantity|QuantityExpr::Fixed" crates/engine/src -S

echo
echo '--- parse_number definition candidates ---'
rg -n "fn parse_number|type .*Number|parse_number\(" crates/engine/src/parser -S

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- oracle_replacement relevant region ---'
sed -n '596,620p' crates/engine/src/parser/oracle_replacement.rs

echo
echo '--- parse_number definition and nearby types ---'
rg -n "fn parse_number|type ParseNumber|parse_number\(" crates/engine/src/parser crates/engine/src -S --max-count 20

echo
echo '--- QuantityExpr::Fixed definition ---'
rg -n "enum QuantityExpr|Fixed \{" crates/engine/src/types crates/engine/src -S --max-count 20

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- parse_number file candidates ---'
fd -a 'parse_number' crates/engine/src/parser crates/engine/src || true

echo
echo '--- QuantityExpr definition candidates ---'
fd -a 'ability.rs' crates/engine/src/types crates/engine/src || true
rg -n "pub enum QuantityExpr|pub struct QuantityExpr|Fixed \{" crates/engine/src/types crates/engine/src/types crates/engine/src -S --max-count 20

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the exact code around the reviewed cast.
sed -n '604,612p' crates/engine/src/parser/oracle_replacement.rs

echo
echo '--- parse_number references in parser files ---'
rg -n "parse_number" crates/engine/src/parser -S --max-count 50

echo
echo '--- QuantityExpr enum definition ---'
rg -n "enum QuantityExpr" crates/engine/src -S --max-count 20

Repository: phase-rs/phase

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the exact parse_number definition and the QuantityExpr type with minimal output.
rg -n "fn parse_number|pub fn parse_number|type ParseNumber" crates/engine/src/parser -S --max-count 20

echo
rg -n "pub enum QuantityExpr" crates/engine/src -S --max-count 10

echo
# If QuantityExpr is in a different file, show the exact match file names only.
rg -l "pub enum QuantityExpr" crates/engine/src -S

Repository: phase-rs/phase

Length of output: 3930


🏁 Script executed:

#!/bin/bash
set -euo pipefail

set +e
rg -n -C 3 "fn parse_number|pub fn parse_number|type ParseNumber" crates/engine/src/parser -S
status1=$?
echo "--- status1=$status1 ---"

echo
rg -n -C 3 "pub enum QuantityExpr" crates/engine/src -S
status2=$?
echo "--- status2=$status2 ---"

exit 0

Repository: phase-rs/phase

Length of output: 30058


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- threshold_n assignment ---'
rg -n -C 4 "threshold_n" crates/engine/src/parser/oracle_replacement.rs -S

echo
echo '--- parse_number in parser files with context ---'
rg -n -C 3 "parse_number" crates/engine/src/parser -S --max-count 30

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n crates/engine/src/parser/oracle_replacement.rs | sed -n '540,620p'

Repository: phase-rs/phase

Length of output: 5247


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 3 "threshold_n" crates/engine/src/parser/oracle_replacement.rs -S

Repository: phase-rs/phase

Length of output: 968


🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -a 'parse*' crates/engine/src/parser

Repository: phase-rs/phase

Length of output: 27577


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact file that defines QuantityExpr and the nearby definition.
file=$(rg -l "pub enum QuantityExpr" crates/engine/src -S | head -n 1)
echo "FILE=$file"
if [ -n "$file" ]; then
  cat -n "$file" | sed -n '1,120p'
fi

Repository: phase-rs/phase

Length of output: 5571


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 2 "parse_number\(" crates/engine/src/parser -S --max-count 50

Repository: phase-rs/phase

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

log=/tmp/coderabbit-shell-logs/shell-output-FZrUV8

echo '--- lines mentioning parse_number in the previous log ---'
rg -n "parse_number|threshold_n|QuantityExpr::Fixed" "$log" -S | head -n 80

Repository: phase-rs/phase

Length of output: 8659


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n crates/engine/src/parser/oracle_replacement.rs | sed -n '560,620p'

Repository: phase-rs/phase

Length of output: 3963


🏁 Script executed:

#!/bin/bash
set -euo pipefail

log=/tmp/coderabbit-shell-logs/shell-output-FZrUV8

# Pull only the exact snippets needed from the previous large output.
rg -n -C 2 "parse_number|threshold_n" "$log" -S | head -n 120

Repository: phase-rs/phase

Length of output: 13133


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the shared numeric primitive implementation and any overflow handling.
cat -n crates/engine/src/parser/oracle_nom/primitives.rs | sed -n '1,240p'

echo
echo '--- digit-number helpers ---'
rg -n -C 3 "parse_digit_number|parse_english_number|u32::MAX|i32::MAX|checked" crates/engine/src/parser/oracle_nom/primitives.rs -S

Repository: phase-rs/phase

Length of output: 14154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Focus on the numeric parser body only.
cat -n crates/engine/src/parser/oracle_nom/primitives.rs | sed -n '1,220p'

Repository: phase-rs/phase

Length of output: 10516


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show just the code paths that construct u32 numbers.
rg -n -C 5 "parse_digit_number|parse_english_number|parse_number\(" crates/engine/src/parser/oracle_nom/primitives.rs -S

Repository: phase-rs/phase

Length of output: 9185


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant portion of the shared numeric primitive implementation.
sed -n '1,220p' crates/engine/src/parser/oracle_nom/primitives.rs

Repository: phase-rs/phase

Length of output: 8976


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Extract the parse_digit_number and parse_english_number sections if present.
rg -n -A 40 -B 10 "fn parse_digit_number|fn parse_english_number|pub fn parse_number" crates/engine/src/parser/oracle_nom/primitives.rs -S

Repository: phase-rs/phase

Length of output: 7375


Use a fallible conversion for the threshold.
threshold_n is u32, so n as i32 can wrap above i32::MAX and flip this gate negative; use i32::try_from(n) and fail closed on overflow.

🤖 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/parser/oracle_replacement.rs` around lines 604 - 612,
Update the threshold construction in the threshold_n handling to convert n with
i32::try_from instead of a wrapping cast. If conversion overflows, fail closed
by skipping or rejecting the replacement condition rather than creating a
negative rhs value.

Source: Path instructions

Comment on lines +19950 to +19954
/// CR 121.2a + CR 121.6b: which [`DrawReplacementScope`] the in-progress draw
/// replacement consult is eligible to match. A draw instruction resolves in two
/// seams — the whole-instruction consult that runs *before* the instruction
/// splits into individual card draws, and the per-card consult that runs for
/// each individual draw — and a shield is scoped to exactly one of them.

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant code and documentation.
git ls-files 'crates/engine/src/types/ability.rs' 'docs/MagicCompRules.txt' | cat

echo
echo '--- ability.rs excerpt ---'
sed -n '19930,19970p' crates/engine/src/types/ability.rs

echo
echo '--- relevant CR search ---'
rg -n -C 2 '121\.2a|121\.6b|draw replacement|replacement' docs/MagicCompRules.txt

Repository: phase-rs/phase

Length of output: 2471


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the exact rules text around the cited sections.
python3 - <<'PY'
from pathlib import Path
path = Path('docs/MagicCompRules.txt')
text = path.read_text()
for needle in ['121.2a', '121.6b']:
    idx = text.find(needle)
    print(f'=== {needle} @ {idx} ===')
    if idx != -1:
        start = max(0, idx - 500)
        end = min(len(text), idx + 1200)
        print(text[start:end])
        print()
PY

Repository: phase-rs/phase

Length of output: 702


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find nearby parser/test code that may explain the intended seam.
rg -n -C 3 'DrawConsultScope|DrawReplacementScope|instruction.*draw|individual.*draw' crates/engine/src/types/ability.rs crates/engine/src -g '!target'

Repository: phase-rs/phase

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the file around the cited lines and any nearby comments.
sed -n '19945,19960p' crates/engine/src/types/ability.rs | cat -n

Repository: phase-rs/phase

Length of output: 1105


🌐 Web query:

Magic Comprehensive Rules 121.2a 121.6b draw replacement text

💡 Result:

In the Magic: The Gathering Comprehensive Rules, rules 121.2a and 121.6b address how replacement effects interact with drawing cards [1][2]. Rule 121.2a states: An instruction to draw multiple cards can be modified by replacement effects that refer to the number of cards drawn. This modification occurs before considering any of the individual card draws [1][2]. Rule 121.6b states: If an effect replaces a draw within a sequence of card draws, the replacement effect is completed before resuming the sequence [1][2].

Citations:


Drop CR 121.6b from this consult-scope doc. It covers completing a replacement before resuming a draw sequence; the pre-split consult seam here is supported by CR 121.2a, not 121.6b.

🤖 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/types/ability.rs` around lines 19950 - 19954, Update the
documentation for DrawReplacementScope to remove the CR 121.6b reference from
this consult-scope description, retaining only CR 121.2a and its explanation of
the pre-split whole-instruction consult.

Source: Path instructions

…per-card split

The count>=min threshold added in the prior commit only ran after the draw
pipeline had split a multi-card draw into single-card events, so Alms
Collector's min:2 was never applicable in production. Move the
instruction-scope replacement consult ahead of the per-card split at the
draw-instruction seam so the printed threshold is evaluated against the full
draw count.

Add an end-to-end test through the production draw path proving a two-card
draw is replaced and a one-card draw is not.

(cherry picked from commit ab673bc)

Restore/adapt the instruction-stage draw-replacement authority onto the
current draw-sequence stack (start_draw_sequence_with_origin +
push_draw_sequence_with_origin), per the maintainer's requested-changes review.

Co-authored-by: nghetienhiep <13849419+nghetienhiep@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Yurii214
Yurii214 force-pushed the fix/5678-draw-count-form-antecedent branch from 679c3cf to fd86d17 Compare July 23, 2026 18:28
@Yurii214

Copy link
Copy Markdown
Contributor Author

Pushed fd86d177 restoring/adapting the instruction-stage consult onto the current draw-sequence stack: effects::draw::start_draw_sequence_with_origin now runs replacement::replace_draw_instruction against the whole count before push_draw_sequence_with_origin splits into per-card events, plus the DrawConsultScope seam marker and the end-to-end production test (draw-two replaced / draw-one untouched via start_draw_sequence).

The structural merge compiles clean — the field/Default/spread plumbing and the draw.rs seam are fine. It stops at one architectural mismatch that I don't think a contributor can resolve without a runtime-authority decision from you:

  • The restored consult gates on DrawReplacementScope::InstructionCount { min } if count >= min, but on current main (c88f2b6…) InstructionCount is a fieldless variant — the parser now carries the printed threshold N as a separate OnlyIfQuantity { EventContextAmount >= N } condition rather than on the scope (E0026: variant InstructionCount does not have a field named min).
  • Reading the threshold back out of that condition at the consult doesn't work either: quantity::resolve_ref's QuantityRef::EventContextAmount arm resolves from already-realized trigger/effect amounts (enclosing_trigger_event_amount, last_effect_count, …). Nothing threads a pending draw's count into that cascade during the pre-split replace_draw_instruction consult, so OnlyIfQuantity { EventContextAmount >= N } can't gate the instruction at this seam.

So the count-gate needs the instruction-stage contract you mentioned owning: either resolve EventContextAmount to the pending instruction count inside replace_draw_instruction, or otherwise surface the parsed threshold to the pre-split consult. Once that shape exists, the matcher + census + production test on this head are ready and I'm happy to finish the contributor-side wiring against it.

@matthewevans

Copy link
Copy Markdown
Member

Request changes — head fd86d177 does not compile, and the diff carries two mutually exclusive designs for where the threshold lives. The good news: the contract you say you're blocked on already exists, and I can point at it.

Thanks for pushing the instruction-stage seam rather than a post-filter — the seam choice is right, and your diagnosis of the EventContextAmount cascade is accurate as far as it goes. It stops one layer short, and that layer is what unblocks you.

🔴 Blocker

1. The head does not compile — InstructionCount is a fieldless variant.
crates/engine/src/game/replacement.rs:5983 and :5994 match DrawReplacementScope::InstructionCount { min }, and the two test fixtures build .draw_scope(DrawReplacementScope::InstructionCount { min: 2 }). On this head the variant is fieldless — crates/engine/src/types/ability.rs:19932 reads InstructionCount, — and this PR's ability.rs hunk adds only DrawConsultScope, never the field. Result is error[E0026]: variant DrawReplacementScope::InstructionCount does not have a field named min, which reds every Rust job (lint, tests 1/2 and 2/2, WASM, card-data). This is on the branch, not on main.

2. The threshold is modeled twice, in two incompatible ways.
crates/engine/src/parser/oracle_replacement.rs:594-617 lowers N into a typed OnlyIfQuantity { lhs: EventContextAmount, comparator: GE, rhs: Fixed(N) }. crates/engine/src/game/replacement.rs:5975-6003 instead reads N off a min field on the scope. Even if min existed, one printed number would then live in two places, and the parser's condition and the matcher's guard could disagree. Exactly one may survive. Keep the parser's typed condition; the scope stays a pure classifier.

3. The contract you're blocked on already exists — the pending event is in scope at condition-evaluation time.
Your comment says nothing threads a pending draw's count into the EventContextAmount cascade. That's true of the global resolver (crates/engine/src/game/quantity.rs:2976 walks post_replacement_token_substitution_countenclosing_trigger_match_countdie_result_this_resolutionenclosing_trigger_event_amountlast_effect_counts_by_player, all already-realized). But the replacement path does not go through a bare resolver call:

  • crates/engine/src/game/replacement.rs:4926-4932evaluate_replacement_condition(..., event: &ProposedEvent) already receives the pending event.
  • crates/engine/src/game/replacement.rs:5068 — the OnlyIfQuantity arm builds its context via replacement_condition_quantity_ctx(state, source_id, affected_object_id, event).
  • crates/engine/src/game/replacement.rs:4840-4863 — that helper already destructures event per-variant (the ProposedEvent::Connive { subject, .. } arm) to populate QuantityContext.

So the pending ProposedEvent::Draw { count } is in hand at the exact point the threshold is evaluated. The fix is to add an event-amount slot to QuantityContext, populate it from ProposedEvent::Draw { count } in replacement_condition_quantity_ctx alongside the existing Connive arm, and have the EventContextAmount arm prefer it. No min field, no new GameState transient for the count, one authority for the threshold, and it gates at both the pre-split and per-card seams for free.

4. The consult scope is restored after the substitute drains, so the substitute's own draws run under the wrong seam.
In replace_draw_instruction (crates/engine/src/game/replacement.rs:8442-8453), apply_pending_post_replacement_effect(...) is called while state.draw_consult_scope is still Instruction; state.draw_consult_scope = prev_scope; lands only afterward. Alms Collector's substitute is "instead you and that player each draw a card" — those two substitute draws are consulted under Instruction, and your own matcher (:5983-5986) return falses every IndividualDraw shield in that scope. A Notion Thief or a Dredge shield would silently fail to see them. Restore prev_scope immediately after replace_event and before the drain. (Independently raised by CodeRabbit; confirmed against the diff.)

🟡 Non-blocking

5. The threshold is dropped on three early-return paths. crates/engine/src/parser/oracle_replacement.rs composes OnlyIfQuantity only at :594-617, immediately before the final return Some(def) at :620. Three earlier returns inside the same count-form block bypass it: :516 (optional draw-skip rider, Obstinate Familiar / Island Sanctuary class), :544 (mandatory draw-skip, Living Conundrum class), :570 (as-long-as gate, Archmage Ascension class). A count-form antecedent reaching any of them yields InstructionCount with no threshold, so it fires on a one-card draw. Related: those branches anchor on the hardcoded "would draw a card" literal when calling compose_draw_replacement_conditions / parse_while_antecedent, which cannot match a count-form antecedent's text. Compose the threshold into def.condition once, before any branch returns.

6. CR 121.6b does not describe consult-scope eligibility. crates/engine/src/types/ability.rs:19950 cites CR 121.2a + CR 121.6b on DrawConsultScope. Verbatim, CR 121.6b reads: "If an effect replaces a draw within a sequence of card draws, the replacement effect is completed before resuming the sequence." That is completion ordering within a sequence, not which scope a consult may match. CR 121.2a alone carries the claim. Drop 121.6b here.

7. Parse-diff evidence is stale for this head. The <!-- coverage-parse-diff --> sticky comment last updated 2026-07-23T16:52:28Z; fd86d177 was pushed 2026-07-23T18:28:43Z, and the card-data job failed to compile, so no parse diff exists for the current head. It will regenerate once the branch builds.

Refuting one CodeRabbit item: n as i32 at :608 does not need i32::try_from. parse_number is reading a printed Oracle numeral; a threshold that overflows i32 is not a reachable input, and a fail-closed branch there would be validation for a case that cannot happen.

✅ Clean

  • Right seam. crates/engine/src/game/effects/draw.rs:218-226 puts the consult in start_draw_sequence_with_origin, above push_draw_sequence_with_origin. That is the single funnel — scry.rs:122, scry.rs:152, connive.rs:127, and start_draw_sequence all route through it — so one consult covers every multi-card draw entry, and it correctly precedes the split that forces count == 1. This matches CR 121.2a verbatim: "An instruction to draw multiple cards can be modified by replacement effects that refer to the number of cards drawn. This modification occurs before considering any of the individual card draws."
  • Antecedent built for the class. oracle_replacement.rs:452-470 composes tag("would draw ") + parse_number + tag(" or more cards"), replacing the hardcoded "one or more cards" tag. That covers every N or more threshold rather than special-casing "two or more", and it reuses nom_primitives::parse_number instead of hand-rolling digit/word matching.
  • The production test is discriminating. alms_class_replaces_instruction_at_pre_split_seam drives the real start_draw_sequence entry and asserts observable deltas — opponent hand unchanged and controller life +3 on the two-card draw, hand +1 and life unchanged on the one-card draw, plus state.draw_sequences.is_empty(). That fails on revert of the draw.rs consult, which is the bar the earlier rounds asked for and the previous heads did not meet.
  • The any_instruction_count_draw_shield early-out enumerates through functioning_abilities::active_replacements, the same iterator the authoritative matcher uses, so the fast path cannot skip a live shield.

Recommendation: rework on a single threshold authority — keep InstructionCount fieldless and keep the parser's OnlyIfQuantity, then thread the pending draw count through replacement_condition_quantity_ctx into the EventContextAmount arm (finding 3). That deletes the { min } assumption that breaks the build, removes the duplicate model, and needs no new contract from us. Then fix the scope-restore ordering (4) and hoist the threshold composition above the early returns (5).

@matthewevans

Copy link
Copy Markdown
Member

Correction to my review above — one credit I gave you was stronger than my evidence supports.

In the ✅ Clean section I wrote that alms_class_replaces_instruction_at_pre_split_seam "fails on revert of the draw.rs consult." I asserted that from reading the test source, not from observing it go red. It cannot be true as stated: head fd86d177 does not compile, so that test has never executed anywhere. Its fixtures also construct .draw_scope(DrawReplacementScope::InstructionCount { min: 2 }) — the same non-existent variant shape that breaks the build — so the test is not expressible against the real type and will have to change materially in the rework.

What I should have written: the test is shaped correctly — it drives the real start_draw_sequence entry and asserts observable deltas rather than internal flags, which is the right pattern and what the earlier rounds asked for. Whether it discriminates is unproven and, at this head, unprovable.

Nothing else in the review changes. The two blockers and the recommendation stand as written, and the other three ✅ items — the right seam at draw.rs:218-226, the N or more antecedent at oracle_replacement.rs:452-470, and the active_replacements early-out — are verified independently of compilation and are unaffected.

Once the rework lands and the test compiles, please confirm it red-first against the reverted draw.rs consult. That is the evidence I credited prematurely, and it is worth having for real.

@matthewevans

Copy link
Copy Markdown
Member

Supplement to my 20:32 review on the same head fd86d177 — four findings that review did not raise, posted now so they land in one rework rather than the next round. Nothing below revisits or softens the two standing blockers (the E0026 compile failure and the duplicate threshold authority); those stand as written.

🔴 Blocker

The pre-split consult changes Quantum Riddler's runtime semantics, untested and unclaimed. any_instruction_count_draw_shield (crates/engine/src/game/replacement.rs:8370) fires for any functioning InstructionCount shield, and Quantum Riddler is already classified InstructionCount at scripts/draw-replacement-corpus.tsv:62. Its Oracle text, verified verbatim via Scryfall:

As long as you have one or fewer cards in hand, if you would draw one or more cards, you draw that many cards plus one instead.

Today a three-card draw meets the per-card seam three times; after this change it is modified once at the instruction seam. That is very likely the CR 121.2a-correct outcome — which is exactly why it needs a regression rather than silence. The parse-diff sticky cannot surface it, because Quantum Riddler's parse signature is unchanged. Please add a Quantum Riddler multi-card regression alongside the Alms Collector one.

Consult scope is restored after the substitute drains. replacement.rs:8449-8453 calls apply_pending_post_replacement_effect(...) while state.draw_consult_scope is still Instruction; the restore state.draw_consult_scope = prev_scope; lands at :8454. Alms Collector's verified Oracle text is "If an opponent would draw two or more cards, instead you and that player each draw a card." Those two substitute draws are therefore consulted under Instruction, and the matcher at :5991-5994 return falses every IndividualDraw shield in that scope — so a Notion Thief or Dredge shield never sees them. Move the prev_scope restore to immediately after replace_event, before the drain.

🟡 Non-blocking — three CR citations that do not describe the code they annotate

Each verbatim from docs/MagicCompRules.txt, grep-verified:

  • replacement.rs:8420// CR 616.1 + CR 614.13: a single mandatory shield is the only synchronous shape. CR 614.13 (line 3109) reads "An effect that modifies how a permanent enters the battlefield may cause other objects to change zones." CR 616.1 alone carries the ordering-choice claim. Drop 614.13.
  • replacement.rs:8457 — cites CR 614.11a for a count modifier leaving a nonzero survivor. CR 614.11a (line 3094) reads "If an effect replaces a draw within a sequence of card draws, all actions required by the replacement are completed, if possible, before resuming the sequence." No sequence resumption happens here; the one-opportunity claim is CR 614.5, already cited correctly on the same comment. Drop 614.11a.
  • types/ability.rs:19950 — cites CR 121.6b for consult-scope eligibility. CR 121.6b (line 1166) reads "If an effect replaces a draw within a sequence of card draws, the replacement effect is completed before resuming the sequence." CR 121.2a alone carries it.

The threshold is dropped on three early returns. crates/engine/src/parser/oracle_replacement.rs composes OnlyIfQuantity only at :597-620, before the final return Some(def). The earlier returns at :516 (draw-skip rider, Obstinate Familiar class), :544 (mandatory draw-skip, Living Conundrum class) and :570 (as-long-as gate, Archmage Ascension class) bypass it, yielding an InstructionCount with no threshold that fires on a one-card draw. Those branches also anchor on the hardcoded "would draw a card" literal when calling compose_draw_replacement_conditions / parse_while_antecedent, which cannot match count-form text. Compose the threshold once, above every branch return.

The PR description no longer matches the diff. The body argues Alms Collector should be IndividualDraw derived from the substitute shape, while the diff adds alms collector ... InstructionCount at scripts/draw-replacement-corpus.tsv:28. The body also states cargo clippy -p engine --lib -- -D warnings — clean and cargo test -p engine --lib (the new test) — green; neither can hold at a head that does not compile. Please refresh it with the rework.

✅ Clean — re-verified independently on this head

  • The seam is right, and I confirmed the funnel rather than taking it on trust. crates/engine/src/game/effects/draw.rs:218-229 places the consult in start_draw_sequence_with_origin, above push_draw_sequence_with_origin. Every external caller — turns.rs:1786, engine_debug.rs:124, gift_delivery.rs:105, connive.rs:127, scry.rs:122, scry.rs:152 — routes through it, so one consult covers every multi-card entry and it precedes the split that forces count == 1. This tracks CR 121.2a verbatim (docs/MagicCompRules.txt:1146): "An instruction to draw multiple cards can be modified by replacement effects that refer to the number of cards drawn. This modification occurs before considering any of the individual card draws."
  • The antecedent is parameterized rather than proliferated. oracle_replacement.rs:452-470 composes tag("would draw ") + nom_primitives::parse_number + tag(" or more cards"), replacing the hardcoded "one or more cards" tag and reusing the existing primitive instead of hand-rolling digit/word matching. Every N or more threshold is covered by one combinator.
  • New-field threading is complete. draw_consult_scope carries #[serde(default, skip_serializing_if = ...)] (game_state.rs:11172-11178), is initialized at :16085, and is added to _gamestate_partition_is_total at :17315. The compiler proves no struct literal was missed — the only two errors are the E0026 pair.

Recommendation: fold these into the same rework as the threshold-authority fix. Move the scope restore above the drain, add the Quantum Riddler regression, hoist the threshold composition above the three early returns, and drop the three CR citations at replacement.rs:8420, replacement.rs:8457, and ability.rs:19950. The seam choice and the combinator work are correct and should not be touched.

@matthewevans

Copy link
Copy Markdown
Member

Correcting the record on where the red comes from, because your last comment is built on the opposite premise and that will cost you another round. Reviewed at head fd86d177f.

The compile error is on this branch, not on main

Your follow-up says "on current main (c88f2b6…) InstructionCount is a fieldless variant". Two problems: c88f2b6 is your own first commit on this branch, not main; and the variant is fieldless on main and on every commit of this branch, including the current head. There is no main-vs-branch mismatch to resolve.

I verified this directly rather than taking it from the CI log:

$ git grep -n "InstructionCount" origin/main -- crates/engine/src/types/ability.rs
origin/main:crates/engine/src/types/ability.rs:19945:    InstructionCount,     # unit variant

$ git grep -c "InstructionCount {" origin/main -- crates/engine/src/
0                                                     # zero brace-form usages, repo-wide

$ git grep -c "InstructionCount" origin/main -- crates/engine/src/game/replacement.rs
0                                                     # main's replacement.rs never mentions it

InstructionCount has never been a struct variant on any ref. Both merge commits on this branch left the types/ability.rs blob byte-identical to the merge-base, so no bad conflict resolution is involved either. The { min } sites at replacement.rs:5987 and :5996 were introduced by your own head commit, which added match arms and test fixtures for a field that was never added to the enum.

That single error[E0026] is what reds all six jobs — Rust lint, both test shards, WASM, Card data, and the aggregator. The lint job died at compile and never reached the parser gate. Bringing the branch current will not fix it, so please don't spend a rebase on it.

Your second claim, "the structural merge compiles clean", is also not accurate — the engine lib fails with 2 errors.

The contract you're waiting on already exists

You wrote that the count-gate needs "the instruction-stage contract you mentioned owning." It's already there, and it's already shaped the way you need:

  • replacement.rs:4926-4932evaluate_replacement_condition already receives the pending &ProposedEvent.
  • replacement.rs:4840-4863replacement_condition_quantity_ctx already destructures it per-variant; the Connive arm is the worked example to copy.

So this is unblocked today. The path I recommended earlier still stands and needs nothing from me: keep InstructionCount fieldless, keep the parser's OnlyIfQuantity lowering at oracle_replacement.rs:604-620, and thread the pending draw count through replacement_condition_quantity_ctx. One threshold authority, not two.

New blocker found this round — please fold it into the same rework

[HIGH] The pre-split consult can pause on NeedsChoice before any draw-sequence frame exists, stranding the choice and dropping the entire draw.

  • effects/draw.rs:222-230 runs replace_draw_instruction before push_draw_sequence_with_origin, and DrawInstructionOutcome::Replaced(result) => return result returns without ever pushing a frame.
  • replacement.rs:8475-8477 routes NeedsChoice into exactly that Replaced arm.
  • The post-choice resume at engine_replacement.rs:856-860 recovers the frame via state.active_draw_sequence().map(|f| f.frame_id) and breaks when there is none.
  • The substitute is lost too: replacement.rs:8449 explicitly skips the post-replacement drain for NeedsChoice.

Your single_mandatory guard at :8430 doesn't cover this, and the reason is subtle: it's computed once against the original event, but replace_eventpipeline_loop re-runs find_applicable_replacements on every iteration against the modified event (:8212-8217, CR 616.1f). A count-modifier that raises the count — the Alhammarret's Archive class your own code anticipates at :8478 — can make a previously sub-threshold shield newly applicable. Two simultaneously-applicable shields then hit replacement_ordering_is_materialNeedsChoice at :8303, which CR 616.1 requires "even when every candidate is mandatory" (verified, MagicCompRules.txt:3173).

Concrete failure: a draw-doubler plus Alms Collector on the same board. The opponent draws, the ordering prompt parks with no frame to anchor it, they lose the entire draw, and the game strands on a choice that can't resume. It needs a two-shield board to reach, but the outcome when reached is severe.

Fix: push the draw-sequence frame before the instruction consult, or park the surviving count in PendingReplacement so engine_replacement.rs:856 has an anchor.

[LOW] Same root: the !single_mandatory branch at :8437-8443 is commented strict-failure but actually proceeds unreplaced. With two count-form shields on the board, Alms Collector just quietly doesn't fire — a wrong result rather than a marker. Either emit a real marker or correct the comment.

Still open from earlier rounds

All seven previous findings stand at this head — nothing has changed on the branch since. The three bad CR citations in particular: 121.6b (line 1166) is sequence-completion ordering, 614.13 (3109) is permanents entering the battlefield, 614.11a (3094) is sequence resumption. None describe the code they annotate. Your correctly-used ones do check out (121.2a, 616.1, 614.5).

One small correction to my own earlier comment: Quantum Riddler is corpus line 63, not 62.


Recommendation: changes requested. The rework is well-defined and doesn't depend on anything from me — fix the E0026 by threading the count through the existing context rather than adding a field, and fold the NeedsChoice frame-anchor fix into the same round.

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

Changes requested — the current head does not compile.

🔴 Blocker

[HIGH] Keep the draw threshold in one typed authority. Evidence: crates/engine/src/game/replacement.rs:5983,5994 destructures DrawReplacementScope::InstructionCount { min }, but crates/engine/src/types/ability.rs:19932 declares the variant fieldless. This explains the current required-check cascade. Preserve the scope as a category and carry N through the existing quantity condition path; then regenerate current-head parse evidence.

Recommendation: request changes; restore compilation and current-head card-data evidence before re-review.

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

Request changes — current head fd86d177f770f2f4fc976fa8ef918ce34a11b81c is not safely portable as a maintainer fixup.

  1. It currently fails to compile: crates/engine/src/game/replacement.rs:5987 and :5996 pattern-match DrawReplacementScope::InstructionCount { min }, while crates/engine/src/types/ability.rs:19932 declares the variant as fieldless. The threshold must remain in the typed replacement-condition authority, not be split into an undeclared scope payload.
  2. This head is DIRTY against current main. An isolated merge attempt produces broad conflicts across the draw sequence, replacement matcher, state, and parser/census surfaces. Those are not a mechanical test-module conflict: the branch’s instruction-stage model has to be reconciled with the current draw pipeline as one reviewed design.
  3. Required CI is red, so the only parse-diff artifact is from the earlier baseline rather than evidence for this head.

Please rebase onto current main, resolve the threshold/scope model coherently at the centralized start_draw_sequence_with_origin → instruction-consult seam, and return with green CI, current parse evidence, and discriminating draw-one/draw-two runtime coverage. I have not pushed an unsafe conflict resolution.

@matthewevans

Copy link
Copy Markdown
Member

Current-head recheck for fd86d177f770f2f4fc976fa8ef918ce34a11b81c: no maintainer port has been pushed.

The branch remains unsafe to transplant. Its instruction-stage work conflicts in the central draw-sequence path, and it does not compile as written: replacement.rs destructures DrawReplacementScope::InstructionCount { min } while the declared enum variant is fieldless. Keep the numeric threshold in the existing typed OnlyIfQuantity(EventContextAmount >= N) condition rather than splitting it into a scope payload.

Please return with a fresh rebase that reconciles the whole instruction-consult path with the current draw sequence, plus green current-head CI/parse evidence and production-path one-card versus two-card coverage. This is a reviewed design change, not a safe mechanical conflict resolution.

@matthewevans matthewevans removed their assignment Aug 1, 2026
@matthewevans

Copy link
Copy Markdown
Member

Rechecked current head fd86d177f770f2f4fc976fa8ef918ce34a11b81c: no contributor update and no maintainer port.

The route stated in the current-head review remains unchanged. The branch is DIRTY/conflicting against today’s main specifically in the centralized draw sequence; it still pattern-matches InstructionCount { min } against the fieldless declared scope; and every required Rust/card-data check plus its parse artifact remains from the failed July run. This must return as a reviewed rebase, not a mechanical conflict resolution.

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.

Alms Collector: 'would draw two or more cards' antecedent never parses to a Draw replacement (CR 121.2a instruction-count gap)

3 participants