Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 13 additions & 0 deletions crates/engine/src/game/quantity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4628,6 +4628,17 @@ fn object_for_scope<'a>(
_ => None,
})
})
// CR 614.12 + CR 613.4c: in an ETB-scoped replacement ("that
// creature enters with ... counters on it, where X is its mana
// value/power/toughness ..."), the recipient IS the entering
// object, not the static replacement source. `ctx.entering`
// carries that identity (mirrors `QuantityContext::self_object`,
// the same convention `CastManaObjectScope::SelfObject` uses for
// Wildgrowth Archaic's "it"). Outside ETB-replacement contexts
// `ctx.entering` is always `None` (only ETB-counter extraction
// sets it), so this fallback is inert for every layer-evaluation
// `Recipient` caller (Blessing of the Nephilim, Civic Saber).
.or_else(|| ctx.entering.and_then(|id| state.objects.get(&id)))
.or_else(|| source_object_for_context(state, ctx.source, ctx.trigger_source.as_ref())),
// CR 603.4: an intervening-if condition is checked at trigger detection
// (current_trigger_event is None then) and re-checked on resolution.
Expand Down Expand Up @@ -4692,6 +4703,8 @@ fn object_id_for_scope(
_ => None,
})
})
// CR 614.12 + CR 613.4c: see the parallel arm in `object_for_scope`.
.or(ctx.entering)
.or_else(|| {
source_object_for_context(state, ctx.source, ctx.trigger_source.as_ref())
.map(|object| object.id)
Expand Down
98 changes: 85 additions & 13 deletions crates/engine/src/parser/oracle_replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4828,21 +4828,18 @@ fn parse_whenever_you_cast_enters_with(
.parse(rest)
.ok()?;

// Optional trailing "where X is [quantity]" clause.
// Optional trailing "where X is [quantity]" clause. Delegate to
// `parse_enters_with_where_x_suffix` — the single authority for this tail
// grammar, already shared with the self-ETB `parse_enters_with_counters`
// path — so composite/offset quantities ("its mana value minus 4"; CR
// 107.1 arithmetic over a CR 202.3 mana-value reference) resolve here too,
// not just atomic `QuantityRef`s. The previous atomic-only
// `parse_quantity_ref` call silently failed (via `?`) on any composite
// expression, misrouting the whole ability to the generic self-ETB
// fallback (Runadi, Behemoth Caller — issue #6492).
let count_expr = match fixed_count {
Some(n) => QuantityExpr::Fixed { value: n as i32 },
None => {
// Expect ", where x is " then a quantity ref.
let (rest, _) = alt((
tag::<_, _, OracleError<'_>>(", where x is "),
tag(", where X is "),
))
.parse(rest)
.ok()?;
let qty_text = rest.trim_end_matches('.').trim();
let qty = crate::parser::oracle_quantity::parse_quantity_ref(qty_text)?;
QuantityExpr::Ref { qty }
}
None => parse_enters_with_where_x_suffix(rest)?,
};

let put_counter = AbilityDefinition::new(
Expand Down Expand Up @@ -19670,6 +19667,81 @@ mod tests {
assert!(parse_replacement_line(text, "Filler").is_none());
}

/// CR 614.1c + CR 202.3 + CR 107.1: Runadi, Behemoth Caller's first ability
/// ("Whenever you cast a creature spell with mana value 5 or greater, that
/// creature enters with X additional +1/+1 counters on it, where X is its
/// mana value minus 4.") parses into a `ChangeZone` replacement scoped to
/// the entering creature (not Runadi herself — issue #6492 regression),
/// with a composite offset quantity over that creature's own mana value.
#[test]
fn parses_runadi_behemoth_caller_replacement() {
let text = "Whenever you cast a creature spell with mana value 5 or greater, that creature enters with X additional +1/+1 counters on it, where X is its mana value minus 4.";
let def = parse_replacement_line(text, "Runadi, Behemoth Caller")
.expect("Runadi's first ability should parse as a replacement");
assert_eq!(def.event, ReplacementEvent::ChangeZone);
assert_eq!(def.destination_zone, Some(Zone::Battlefield));

// valid_card: creature with mana value >= 5, controlled by Runadi's
// controller — NOT SelfRef (the pre-fix regression).
let TargetFilter::Typed(ref tf) = def.valid_card.as_ref().expect("valid_card set") else {
panic!("expected Typed filter, got {:?}", def.valid_card);
};
assert_eq!(tf.type_filters, vec![TypeFilter::Creature]);
assert_eq!(tf.controller, Some(ControllerRef::You));
assert!(
tf.properties.iter().any(|p| matches!(
p,
FilterProp::Cmc {
comparator: Comparator::GE,
value: QuantityExpr::Fixed { value: 5 },
}
)),
"valid_card must gate on mana value >= 5, got {:?}",
tf.properties
);

// execute: PutCounter { target: SelfRef, count: Offset(its mana value, -4) }.
let exec = def.execute.as_ref().expect("execute set");
let Effect::PutCounter {
counter_type,
count,
target,
} = &*exec.effect
else {
panic!("expected PutCounter, got {:?}", exec.effect);
};
assert_eq!(counter_type, &CounterType::Plus1Plus1);
assert_eq!(target, &TargetFilter::SelfRef);
assert_eq!(
count,
&QuantityExpr::Offset {
inner: Box::new(QuantityExpr::Ref {
qty: QuantityRef::ObjectManaValue {
scope: crate::types::ability::ObjectScope::Recipient,
},
}),
offset: -4,
},
"count must be the entering creature's own mana value minus 4, not a \
garbage literal or Runadi's own mana value"
);
}

/// Regression: an unparseable composite quantity in the "where X is" clause
/// must still fail closed (return `None`, falling through to the generic
/// self-ETB fallback) rather than silently absorbing the condition text as
/// a garbage counter-type literal — the exact failure mode issue #6492
/// reported before the fix.
#[test]
fn whenever_you_cast_enters_with_garbage_quantity_fails_closed() {
let text = "Whenever you cast a creature spell with mana value 5 or greater, that creature enters with X additional +1/+1 counters on it, where X is its unrecognized nonsense value minus 4.";
assert!(
parse_whenever_you_cast_enters_with(&text.to_lowercase(), text).is_none(),
"an unparseable quantity clause must fail this combinator closed, not \
succeed with a wrong AST"
);
}

/// Regression: "Whenever you cast" with a fixed additional counter amount
/// (no "where X is …" tail) also parses cleanly. Covers the cousin shape
/// where the count is a literal number.
Expand Down
52 changes: 52 additions & 0 deletions crates/engine/src/parser/oracle_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5532,6 +5532,18 @@ fn parse_counter_spec_after_lead(
|input| {
let (input, expr) = nom_quantity::parse_quantity_expr_number(input)?;
let (input, _) = tag_e::<_, _, OracleError<'_>>(" ").parse(input)?;
// CR 122.1: "with N or more/or greater <type> counters" — redundant
// with the already-GE `with` lead (mirrors the CMC "N or greater"
// handling above `parse_counter_suffix`'s call site), but the
// qualifier must still be consumed here or it leaks into the
// counter-type slice below (issue #6492: "or more +1/+1" parsed as
// a garbage counter type on Runadi, Behemoth Caller's haste static).
let input = alt((
tag_e::<_, _, OracleError<'_>>("or more "),
tag_e("or greater "),
))
.parse(input)
.map_or(input, |(rest, _)| rest);
Ok((input, expr))
},
));
Expand Down Expand Up @@ -11796,6 +11808,46 @@ mod tests {
));
}

/// CR 122.1 + CR 613.4c: issue #6492 — Runadi, Behemoth Caller's haste
/// static ("Creatures you control with three or more +1/+1 counters on
/// them have haste.") requires "three or more" to consume cleanly instead
/// of leaking "or more" into the counter-type slice (`Generic("or more
/// +1/+1")` pre-fix — no creature ever matched the filter, so haste never
/// applied). "with N counters" is already GE per the `with` lead, so "or
/// more"/"or greater" is a redundant qualifier that must be consumed, not
/// carried into the counter type.
#[test]
fn parse_counter_suffix_three_or_more_plus1plus1() {
let result = parse_counter_suffix(" with three or more +1/+1 counters on them");
assert!(result.is_some());
let (prop, _consumed) = result.unwrap();
assert!(matches!(
prop,
FilterProp::Counters {
counters: CounterMatch::OfType(CounterType::Plus1Plus1),
comparator: Comparator::GE,
count: QuantityExpr::Fixed { value: 3 },
}
));
}

/// Sibling coverage: "or greater" (not just "or more") must also be
/// stripped cleanly.
#[test]
fn parse_counter_suffix_two_or_greater_stun() {
let result = parse_counter_suffix(" with two or greater stun counters on it");
assert!(result.is_some());
let (prop, _consumed) = result.unwrap();
assert!(matches!(
prop,
FilterProp::Counters {
counters: CounterMatch::OfType(CounterType::Stun),
comparator: Comparator::GE,
count: QuantityExpr::Fixed { value: 2 },
}
));
}

#[test]
fn parse_counter_suffix_not_counter_phrase() {
let result = parse_counter_suffix(" with power 3 or greater");
Expand Down
7 changes: 5 additions & 2 deletions crates/engine/src/types/ability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5294,9 +5294,12 @@ pub enum ObjectScope {
Target,
/// CR 613.4c + CR 115.10: The object currently receiving an effect.
/// In layer evaluation this is the per-object recipient. Outside layers,
/// it resolves to the first object target when present, then to the source.
/// it resolves to the first object target when present, then to the
/// entering object of an ETB-scoped replacement, then to the source.
/// Used for recipient-relative "its colors" boosts such as Blessing of
/// the Nephilim and Civic Saber.
/// the Nephilim and Civic Saber, and for "its mana value"/"its power"
/// quantities inside "that creature enters with ... counters" replacement
/// effects (Runadi, Behemoth Caller).
Recipient,
/// CR 603.2: The object referenced by the current trigger event.
EventSource,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,7 @@ mod roots_of_wisdom_if_you_cant_draw;
mod roughshod_mentor_green_trample_grant;
mod rules;
mod run_for_your_life_escape;
mod runadi_behemoth_caller_etb_counters;
mod saddle_become_effect;
mod saddle_state_model;
mod saruman_white_hand_amass;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! Runadi, Behemoth Caller — RUNTIME witness for the ETB-counter replacement
//! and its downstream haste consequence (issue #6492).
//!
//! Oracle (verified via Scryfall, card j22/44):
//! "Whenever you cast a creature spell with mana value 5 or greater, that
//! creature enters with X additional +1/+1 counters on it, where X is its
//! mana value minus 4."
//! "Creatures you control with three or more +1/+1 counters on them have
//! haste."
//! "{T}: Add {G}."
//!
//! Pre-fix, the composite "its mana value minus 4" clause failed to parse
//! (the combinator only supported atomic quantity refs), misrouting the
//! ability to the self-ETB fallback with `valid_card: SelfRef` — Runadi would
//! try to put counters on HERSELF, not the cast creature, and the cast
//! creature would enter with 0 counters regardless of its mana value.
//!
//! CR references (verified against docs/MagicCompRules.txt):
//! - CR 614.1c: "[this permanent] enters with ..." is a replacement effect.
//! - CR 202.3: mana value.
//! - CR 122.1a: a +1/+1 counter adds 1 to power and 1 to toughness.
//!
//! Discrimination: a mana-value-8 creature must enter with 4 counters (8-4)
//! and gain Haste from the second ability's 3-or-more threshold; a
//! mana-value-4 creature must enter with 0 counters (filter excludes it) and
//! no Haste.

use engine::game::layers::evaluate_layers;
use engine::game::scenario::{GameScenario, P0};
use engine::types::counter::CounterType;
use engine::types::keywords::Keyword;
use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit};
use engine::types::phase::Phase;
use engine::types::zones::Zone;
use engine::types::ObjectId;

const RUNADI: &str = "Whenever you cast a creature spell with mana value 5 or greater, that creature enters with X additional +1/+1 counters on it, where X is its mana value minus 4.\nCreatures you control with three or more +1/+1 counters on them have haste.\n{T}: Add {G}.";

/// Cast a green creature of the given mana value (shards: GG, generic = mv -
/// 2) while Runadi is on P0's battlefield. Returns `(counters on the
/// entrant, entrant has Haste)`.
fn cast_creature_with_runadi(name: &str, mana_value: u32) -> (u32, bool) {
let mut scenario = GameScenario::new();
scenario.at_phase(Phase::PreCombatMain);

scenario.add_creature_from_oracle(P0, "Runadi, Behemoth Caller", 1, 3, RUNADI);

let generic = mana_value.saturating_sub(2);
let spell = scenario
.add_creature_to_hand_from_oracle(P0, name, 1, 1, "")
.with_mana_cost(ManaCost::Cost {
shards: vec![ManaCostShard::Green, ManaCostShard::Green],
generic,
})
.id();

scenario.with_mana_pool(
P0,
(0..generic)
.map(|_| ManaUnit::new(ManaType::Colorless, ObjectId(0), false, Vec::new()))
.chain((0..2).map(|_| ManaUnit::new(ManaType::Green, ObjectId(0), false, Vec::new())))
.collect(),
);

let mut runner = scenario.build();
let outcome = runner.cast(spell).resolve();

let entered = outcome
.find_object(|o| o.name == name && o.zone == Zone::Battlefield)
.expect("cast creature must have entered the battlefield");

let counters = outcome.counters(entered, CounterType::Plus1Plus1);

// Force a full layers re-evaluation to read the haste static's current
// grant — mirrors the established convention (see
// `frostcliff_siege_anchor_word_modes.rs`) since not every action reliably
// bumps `layers_dirty` on its own; this test cares about the counters
// (the actual bug) driving the static's condition, not about dirty-bit
// plumbing.
let state = runner.state_mut();
state.layers_dirty.mark_full();
evaluate_layers(state);
let has_haste = runner
.state()
.objects
.get(&entered)
.is_some_and(|obj| obj.keywords.contains(&Keyword::Haste));
(counters, has_haste)
}

#[test]
fn runadi_grants_mv_minus_4_counters_and_downstream_haste_at_mv8() {
// MV 8: X = 8 - 4 = 4 counters, crossing the "three or more" haste
// threshold on the SAME creature.
assert_eq!(
cast_creature_with_runadi("Test Behemoth", 8),
(4, true),
"an MV8 creature must enter with 4 counters and gain haste from the \
3-or-more threshold; (0, false) means the ETB-counter replacement \
never fired (issue #6492 regression)"
);
}

#[test]
fn runadi_grants_exactly_one_counter_at_mv5_threshold() {
// MV 5: X = 5 - 4 = 1 counter — below the haste threshold.
assert_eq!(
cast_creature_with_runadi("Test Whelp", 5),
(1, false),
"an MV5 creature (the exact threshold) must enter with exactly 1 \
counter and not yet have haste"
);
}

#[test]
fn runadi_grants_no_counters_below_mv5_threshold() {
// MV 4: below the "mana value 5 or greater" filter — the replacement
// must not apply at all (proves the Cmc filter still gates correctly and
// the fix didn't turn this into an unconditional counter grant).
assert_eq!(
cast_creature_with_runadi("Test Sprite", 4),
(0, false),
"an MV4 creature must NOT receive any counters (mana value filter \
excludes it) and must not have haste"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading