Skip to content
Merged
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
76 changes: 74 additions & 2 deletions crates/engine/src/game/effects/add_target_replacement.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
use crate::game::targeting::resolve_event_context_target;
use crate::game::targeting::{extract_source_from_event, resolve_event_context_target};
use crate::types::ability::{
AbilityDefinition, DamageTargetFilter, DamageTargetPlayerScope, Duration, Effect, EffectError,
EffectKind, ReplacementCondition, ReplacementDefinition, ResolvedAbility, RestrictionExpiry,
TargetFilter, TargetRef,
};
use crate::types::events::GameEvent;
use crate::types::game_state::GameState;
use crate::types::identifiers::ObjectId;
use crate::types::replacements::ReplacementEvent;

pub(crate) fn expiry_from_duration(
Expand Down Expand Up @@ -47,6 +48,76 @@ fn replacement_with_ability_expiry(
replacement
}

/// CR 603.2 + CR 603.3b + CR 117.3b: Concretize
/// `TRIGGERING_SPELL_PLACEHOLDER` — the parse-time sentinel
/// `parse_whenever_you_cast_enters_with_trigger` embeds inside a floating
/// (`TargetFilter::None`) replacement's `valid_card` — to the SPECIFIC spell
/// object referenced by the currently-resolving triggered ability's own
/// originating event (Runadi, Behemoth Caller and the Wildgrowth Archaic
/// cousin family — issue #6492 review).
///
/// Without this, a bare type/mana-value filter would let a DIFFERENT
/// qualifying creature — cast by the active player during the CR 117.3b
/// priority window between this trigger resolving and the originally-cast
/// spell resolving — consume the one-shot install first, leaving the intended
/// entrant uncountered. `state.current_trigger_event` is exactly this
/// ability's own trigger event (set by `push_resolving_trigger_context` for
/// the duration of its resolution — see `game/triggers.rs`), so
/// `extract_source_from_event` yields the specific cast spell's `ObjectId`.
///
/// If the event carries no extractable source, this fails CLOSED — matching
/// no object via the `ObjectId(0)` sentinel (never a real permanent) — rather
/// than silently widening back to the bare filter, which would reopen the
/// exact bug this binding exists to close.
///
/// No-op for every other floating-replacement install (Kaya's until-EOT token
/// doubler, Rankle and Torbran's damage-modification shields): none of them
/// ever embed the placeholder, so the walk finds nothing to replace.
fn bind_replacement_to_trigger_source(replacement: &mut ReplacementDefinition, state: &GameState) {
let Some(valid_card) = replacement.valid_card.as_mut() else {
return;
};
if !target_filter_contains_placeholder(valid_card) {
return;
}
let bound = state
.current_trigger_event
.as_ref()
.and_then(extract_source_from_event)
.unwrap_or(ObjectId(0));
concretize_triggering_spell_placeholder(valid_card, bound);
}

fn target_filter_contains_placeholder(filter: &TargetFilter) -> bool {
match filter {
TargetFilter::SpecificObject { id } => {
*id == crate::types::identifiers::TRIGGERING_SPELL_PLACEHOLDER
}
TargetFilter::And { filters } | TargetFilter::Or { filters } => {
filters.iter().any(target_filter_contains_placeholder)
}
TargetFilter::Not { filter } => target_filter_contains_placeholder(filter),
_ => false,
}
}

fn concretize_triggering_spell_placeholder(filter: &mut TargetFilter, bound: ObjectId) {
match filter {
TargetFilter::SpecificObject { id }
if *id == crate::types::identifiers::TRIGGERING_SPELL_PLACEHOLDER =>
{
*id = bound;
}
TargetFilter::And { filters } | TargetFilter::Or { filters } => {
for f in filters.iter_mut() {
concretize_triggering_spell_placeholder(f, bound);
}
}
TargetFilter::Not { filter } => concretize_triggering_spell_placeholder(filter, bound),
_ => {}
}
}

// CR 614.12a + CR 707.2: If the resolving spell chose the object to copy, bind
// that object into the delayed enter-as-copy replacement when the shield is
// created so the later entry event does not ask for a new copy source.
Expand Down Expand Up @@ -237,7 +308,8 @@ pub fn resolve(
// Slaughter's "If a source you control would deal damage this turn,
// it deals that much damage plus 1 instead.").
if matches!(target, TargetFilter::None) {
let replacement = replacement_with_ability_expiry(replacement, ability);
let mut replacement = replacement_with_ability_expiry(replacement, ability);
bind_replacement_to_trigger_source(&mut replacement, state);
state.pending_damage_replacements.push(replacement);
attached += 1;
} else {
Expand Down
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
22 changes: 18 additions & 4 deletions crates/engine/src/parser/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ use super::oracle_modal::{
use super::oracle_replacement::{
find_copy_verb_present, lower_as_enters_becomes_choice_modal,
lower_as_enters_or_face_up_counters, lower_replacement_ir, parse_replacement_line,
parse_replacement_line_ir,
parse_replacement_line_ir, parse_whenever_you_cast_enters_with_trigger,
};
use super::oracle_saga::{is_saga_chapter, parse_saga_chapters};
use super::oracle_spacecraft::parse_spacecraft_threshold_lines;
Expand Down Expand Up @@ -5044,9 +5044,6 @@ pub(crate) fn parse_oracle_ir(
// are CR 614.1c replacement effects, not triggered abilities — despite
// the "whenever"/"when" framing. Intercept before the generic trigger
// dispatch routes them through the SpellCast / ChangesZone matcher.
// Applies to Wildgrowth Archaic and cousin cards (Runadi, Boreal
// Outrider, Torgal, Dragon Broodmother, …). `parse_replacement_line`
// handles all the compositional variants (fixed / X / "where X is …").
//
// CR 603.2 exclusion: an ETB-with-counter TRIGGER ("… enters with a
// counter on it, <consequence>") watches for ANY (untyped) counter and
Expand All @@ -5064,6 +5061,23 @@ pub(crate) fn parse_oracle_ir(
&& scan_contains(&lower, "enters with")
&& !scan_contains(&lower, "enters this way,")
{
// CR 603.1 + CR 603.3 + CR 614.1c/614.12: "Whenever you cast [spell],
// that [subject] enters with … counter(s) on it[, where X is …]"
// (Wildgrowth Archaic and cousin cards — Runadi, Boreal Outrider,
// Torgal, Dragon Broodmother, …) is a TRIGGERED ability (CR 603.1),
// not an object-hosted static replacement — the entering-with-counters
// effect must survive the source leaving the battlefield after the
// trigger resolves but before the cast spell does (issue #6492
// review). Try this shape's dedicated trigger recognizer FIRST so it
// never falls through to the generic object-hosted replacement path.
if let Some(trigger) = parse_whenever_you_cast_enters_with_trigger(&line, card_name) {
emitter.trigger_ir_at(item_line, TriggerNodeIr::from_definition(&line, trigger));
i += 1;
continue;
}
// Every other "… enters with …" shape here (kicker-conditional
// "if ~ was kicked, it enters with …", external "[type] enters
// with …", etc.) is a genuine CR 614.1c object-hosted replacement.
if let Some(replacement_ir) = parse_replacement_line_ir(&line, card_name) {
emitter.emit_at(item_line, OracleNodeIr::Replacement(replacement_ir));
i += 1;
Expand Down
Loading
Loading