Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/engine/src/game/effects/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,17 @@ pub(crate) fn start_draw_sequence_with_origin(
origin: DrawSequenceOrigin,
events: &mut Vec<GameEvent>,
) -> replacement::ReplacementResult {
// CR 121.2a: A count-form draw replacement ("If [a player] would draw N or
// more cards, ...") modifies the draw INSTRUCTION "before considering any of
// the individual card draws". Consult those instruction-scoped shields here,
// against the whole `count`, before the instruction splits into individual
// draws below — the per-unit seam only ever sees `count == 1`, so it can
// never enforce a threshold of two or more.
let (count, applied) =
match replacement::replace_draw_instruction(state, player, count, applied, events) {
replacement::DrawInstructionOutcome::Proceed { count, applied } => (count, applied),
replacement::DrawInstructionOutcome::Replaced(result) => return result,
};
let frame_id = state.push_draw_sequence_with_origin(player, count, applied, origin);
resume_draw_sequence(state, frame_id, events)
}
Expand Down
365 changes: 364 additions & 1 deletion crates/engine/src/game/replacement.rs

Large diffs are not rendered by default.

86 changes: 79 additions & 7 deletions crates/engine/src/parser/oracle_replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,20 +446,33 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option<Replaceme
// "would draw a card" hooks one individual draw; a count-form "would draw one
// or more cards" hooks the instruction, which CR 121.2a modifies "before
// considering any of the individual card draws".
let draw_scope = nom_primitives::scan_at_word_boundaries(&lower, |i| {
// The count-form arm captures N via `parse_number` (build for the class: any
// "<N> or more cards" threshold, not a "two or more" special case). A count
// form scopes the whole instruction (InstructionCount); "N >= 2" additionally
// carries a typed threshold (Alms Collector) wired below as an OnlyIfQuantity
// over the pending draw count. "one or more" (N == 1) is vacuously true, so it
// takes no threshold — its InstructionCount comes from the antecedent alone.
let draw_antecedent = nom_primitives::scan_at_word_boundaries(&lower, |i| {
alt((
value(
DrawReplacementScope::IndividualDraw,
(DrawReplacementScope::IndividualDraw, None),
tag::<_, _, OracleError<'_>>("would draw a card"),
),
value(
DrawReplacementScope::InstructionCount,
tag("would draw one or more cards"),
),
(
tag("would draw "),
nom_primitives::parse_number,
tag(" or more cards"),
)
.map(|(_, n, _)| {
(
DrawReplacementScope::InstructionCount,
if n >= 2 { Some(n) } else { None },
)
}),
))
.parse(i)
});
if let Some(draw_scope) = draw_scope {
if let Some((draw_scope, threshold_n)) = draw_antecedent {
// CR 614.1a: An "As long as <state>, if you would draw a
// card, ..." gate (Archmage Ascension) precedes the draw antecedent with
// its own comma clause. Split it off so effect extraction anchors on the
Expand Down Expand Up @@ -581,6 +594,29 @@ fn parse_replacement_line_inner(text: &str, card_name: &str) -> Option<Replaceme
WhileAntecedent::Absent => {}
}
}
// 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,
};
Comment on lines +604 to +612

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

def.condition = Some(match def.condition.take() {
Some(existing) => ReplacementCondition::And {
conditions: vec![existing, threshold],
},
None => threshold,
});
}
Comment on lines +597 to +619

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

return Some(def);
}

Expand Down Expand Up @@ -18765,6 +18801,42 @@ mod tests {
));
}

#[test]
fn alms_collector_count_form_threshold_gates_on_instruction_draw_count() {
// #5678 / CR 121.2a: "If an opponent would draw two or more cards, instead
// you and that player each draw a card." The count-form antecedent must
// (1) scope the whole instruction (InstructionCount), (2) carry N=2 as a
// typed OnlyIfQuantity over the pending draw count (EventContextAmount) so
// a one-card draw does not match, and (3) apply only to an opponent's draw.
let def = parse_replacement_line(
"If an opponent would draw two or more cards, instead you and that player each draw a card.",
"Alms Collector",
)
.expect("Alms Collector's count-form antecedent must lower to a Draw replacement");
assert_eq!(def.event, ReplacementEvent::Draw);
assert_eq!(def.draw_scope, Some(DrawReplacementScope::InstructionCount));
assert_eq!(def.valid_player, Some(ReplacementPlayerScope::Opponent));
assert_eq!(
def.condition,
Some(ReplacementCondition::OnlyIfQuantity {
lhs: QuantityExpr::Ref {
qty: QuantityRef::EventContextAmount,
},
comparator: Comparator::GE,
rhs: QuantityExpr::Fixed { value: 2 },
active_player_req: None,
}),
"N=2 must lower to OnlyIfQuantity(EventContextAmount >= 2)"
);
// The substitute is a fixed per-player draw (you + the drawing opponent),
// not a count-modifier -- InstructionCount comes from the antecedent
// threshold, not the execute shape (the discipline the maintainer required).
assert!(matches!(
def.execute.as_deref().map(|a| &*a.effect),
Some(Effect::Draw { .. })
));
}

#[test]
fn draw_replacement_leading_instead_prefix_blood_scrivener() {
// CR 614.1a: "instead you draw two cards" — leading "instead" form with
Expand Down
24 changes: 24 additions & 0 deletions crates/engine/src/types/ability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19947,6 +19947,30 @@ pub enum PlaneswalkReplacementScope {
PlanarDieOnly,
}

/// 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.
Comment on lines +19950 to +19954

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

///
/// The default, [`Individual`](Self::Individual), is the per-card seam: an
/// `IndividualDraw` shield hooks each card, and a count-form
/// `InstructionCount` shield hooks a non-split whole-count draw (the turn-based
/// draw step, connive, gift) at or above its printed threshold.
/// [`Instruction`](Self::Instruction) is set only by
/// `game::replacement::replace_draw_instruction` for the pre-split consult, so
/// only `InstructionCount` shields see the whole instruction there — an
/// `IndividualDraw` shield must wait for its individual card.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum DrawConsultScope {
/// The per-card / non-split draw seam. See the type-level docs.
#[default]
Individual,
/// The pre-split whole-instruction seam (CR 121.2a). Only `InstructionCount`
/// count-form shields are eligible.
Instruction,
}

/// CR 614.1a: Which player(s) a replacement effect applies to, scoped relative
/// to the replacement source player. For permanents/spells this is the source's
/// controller; for cards outside the battlefield/stack, CR 109.4 + CR 108.4a
Expand Down
16 changes: 16 additions & 0 deletions crates/engine/src/types/game_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10980,6 +10980,13 @@ impl<'de> Deserialize<'de> for PendingLiminalEntryResume {
}
}

/// serde skip predicate for [`GameState::draw_consult_scope`] — the transient
/// consult scope is only ever `Instruction` mid-consult, which never spans a
/// serialization boundary, so the `Individual` default is elided.
fn draw_consult_scope_is_individual(scope: &crate::types::ability::DrawConsultScope) -> bool {
matches!(scope, crate::types::ability::DrawConsultScope::Individual)
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameState {
pub turn_number: u32,
Expand Down Expand Up @@ -11165,6 +11172,13 @@ pub struct GameState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_replacement_token_substitution_count: Option<i32>,

/// CR 121.2a: which draw-replacement scope the in-progress replacement
/// consult is eligible to match. See [`DrawConsultScope`]. Transient — set to
/// `Instruction` only for the duration of the pre-split whole-instruction
/// consult in `game::replacement::replace_draw_instruction`, and restored to
/// the `Individual` default (skipped by serde) immediately afterward.
#[serde(default, skip_serializing_if = "draw_consult_scope_is_individual")]
pub draw_consult_scope: crate::types::ability::DrawConsultScope,
/// CR 614.12a + CR 707.9 + CR 603.2: `ZoneChanged`-to-battlefield events
/// for an object whose entry is paused mid-resolution awaiting an
/// interactive choice (e.g. `WaitingFor::CopyTargetChoice`). Per CR
Expand Down Expand Up @@ -16071,6 +16085,7 @@ impl GameState {
replacement_may_cost_paused: false,
post_replacement_token_choice_applied: None,
post_replacement_token_substitution_count: None,
draw_consult_scope: crate::types::ability::DrawConsultScope::Individual,
deferred_entry_events: Vec::new(),
layers_dirty: LayersDirty::full(),
static_gate_truth: im::HashMap::new(),
Expand Down Expand Up @@ -17300,6 +17315,7 @@ fn _gamestate_partition_is_total(s: &GameState) {
pending_replacement: _,
replacement_may_cost_paused: _,
post_replacement_token_choice_applied: _,
draw_consult_scope: _,
deferred_entry_events: _,
layers_dirty: _,
static_gate_truth: _,
Expand Down
1 change: 1 addition & 0 deletions scripts/draw-replacement-corpus.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
#
abundance Draw Optional none Choose - IndividualDraw
alhammarret's archive Draw Mandatory none Draw nested-draw IndividualDraw
alms collector Draw Mandatory none Draw nested-draw InstructionCount
archmage ascension Draw Optional none SearchLibrary - IndividualDraw
asmodeus the archfiend Draw Mandatory none ExileTop - IndividualDraw
bard, king of dale Draw Mandatory none Draw nested-draw IndividualDraw
Expand Down
9 changes: 9 additions & 0 deletions scripts/draw_replacement_census.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,15 @@ def classify_scope(card: str, repl: dict) -> str:
f"(and to KNOWN_QUANTITY_MODIFICATIONS), then re-freeze."
)

# CR 121.2a: a typed antecedent threshold -- "draw N or more cards" lowered to
# an OnlyIfQuantity gating on the event's own draw count (EventContextAmount) --
# modifies the whole instruction before any individual draw, so it is
# InstructionCount even when the substitute is a fixed draw (Alms Collector).
# The instruction-count signal lives in the condition subtree, not just the
# execute's count.
condition = repl.get("condition")
if condition is not None and reads_event_context_amount(condition):
return "InstructionCount"
effect = ((repl.get("execute") or {}).get("effect")) or {}
if effect.get("type") == "Draw" and reads_event_context_amount(effect.get("count")):
return "InstructionCount"
Expand Down
Loading