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
79 changes: 62 additions & 17 deletions crates/engine/src/parser/oracle_effect/assembly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1625,9 +1625,11 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition {
// 1 runs as printed, then the tail runs from
// `else_ability`. Single-clause bases collapse to the
// prior shape (empty tail → no `else_ability`).
// U6-C2: `Instead` is the ONLY handler that binds FirstEmitted
// (CR 608.2c — the override replaces the FIRST printed
// instruction). Do not unify it with the `Last*` selectors.
// U6-C2: `Instead` is the ONLY handler that begins from
// FirstEmitted. Its one structural refinement is an optional
// payment: there, the override replaces the immediate
// `IfYouDo` continuation, not the payment instruction.
// Do not unify it with the `Last*` selectors.
let bound = env.resolve(
&defs,
AntecedentSelector::FirstEmitted,
Expand All @@ -1649,21 +1651,51 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition {
append_to_deepest_sub_ability(&mut root, Some(Box::new(next)));
}
let mut instead = *instead_def.clone();
// CR 702.33d + CR 707.10: Resolve "create N of those
// tokens" anaphor against the root (the antecedent
// for a multi-clause base is the first printed clause).
rewrite_those_tokens_from_antecedent(&mut instead.effect, &root.effect);
if rewrite_counter_instead_target_from_antecedent(
&mut instead.effect,
&root.effect,
) {
instead.target_choice_timing = root.target_choice_timing;
}
if has_explicit_player_target(root.effect.as_ref()) {
rewrite_player_anaphor_targets_in_definition(&mut instead);
if instead_replaces_optional_payment_continuation(&root) {
// CR 608.2c: after "you may pay ... If you do, X",
// a later "Y instead" modifies X, not the preceding
// payment instruction. Keep the payment as the root
// and attach the override to its paid continuation.
let continuation = root
.sub_ability
.as_deref_mut()
.expect("the optional-payment continuation was checked");
rewrite_those_tokens_from_antecedent(
&mut instead.effect,
&continuation.effect,
);
if rewrite_counter_instead_target_from_antecedent(
&mut instead.effect,
&continuation.effect,
) {
instead.target_choice_timing =
continuation.target_choice_timing;
}
if has_explicit_player_target(continuation.effect.as_ref()) {
rewrite_player_anaphor_targets_in_definition(&mut instead);
}
instead.else_ability = continuation.sub_ability.take();
continuation.sub_ability = Some(Box::new(instead));
} else {
// CR 702.33d + CR 707.10: Resolve "create N of those
// tokens" anaphor against the root (the antecedent
// for a multi-clause base is the first printed clause).
rewrite_those_tokens_from_antecedent(
&mut instead.effect,
&root.effect,
);
if rewrite_counter_instead_target_from_antecedent(
&mut instead.effect,
&root.effect,
) {
instead.target_choice_timing = root.target_choice_timing;
}
if has_explicit_player_target(root.effect.as_ref()) {
rewrite_player_anaphor_targets_in_definition(&mut instead);
}
instead.else_ability = root.sub_ability.take();
root.sub_ability = Some(Box::new(instead));
}
instead.else_ability = root.sub_ability.take();
root.sub_ability = Some(Box::new(instead));
defs.push(root);
if let Some(id) = root_id {
env.arena.reinstate(id);
Expand Down Expand Up @@ -2897,6 +2929,19 @@ fn rebind_condition_instead_damage_anaphor(
false
}

/// CR 608.2c: identify the structural "you may pay ... If you do, X" form
/// whose immediate paid continuation, rather than the payment itself, can be
/// modified by a following "Y instead" clause.
fn instead_replaces_optional_payment_continuation(root: &AbilityDefinition) -> bool {
matches!(root.effect.as_ref(), Effect::PayCost { .. })
&& root.sub_ability.as_ref().is_some_and(|continuation| {
continuation
.condition
.as_ref()
.is_some_and(AbilityCondition::is_optional_effect_performed)
})
}

#[cfg(test)]
mod arena_tests {
use super::*;
Expand Down
15 changes: 14 additions & 1 deletion crates/engine/src/parser/oracle_effect/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2399,6 +2399,8 @@ pub(super) fn rewrite_counter_instead_target_from_antecedent(
if !matches!(current_target, TargetFilter::SelfRef) {
return false;
}
// CR 608.2c + CR 115.1: an instead clause later in the same instruction
// reuses the original chosen target rather than announcing a new target.
// Existing attachment-host case — only when the antecedent is itself a `PutCounter`.
// Preserved verbatim (clone the host filter) so attachment-host cards stay byte-identical.
if let Effect::PutCounter {
Expand All @@ -2410,7 +2412,18 @@ pub(super) fn rewrite_counter_instead_target_from_antecedent(
*current_target = antecedent_target.clone();
return true;
}
return false;
match antecedent_target {
// A printed target is selected once for the root instruction; the
// override must inherit that selection rather than open a new slot.
TargetFilter::Typed(_) => *current_target = TargetFilter::ParentTarget,
// Event and parent anaphors already identify the antecedent object
// at resolution. Reuse the same reference for a bare "it" override.
TargetFilter::ParentTarget
| TargetFilter::ParentTargetSlot { .. }
| TargetFilter::TriggeringSource => *current_target = antecedent_target.clone(),
_ => return false,
}
return true;
}
// FIX A′ — CR 608.2c: an instead-override "Put a +1/+1 counter on it" whose antecedent
// is a typed-targeted non-counter clause (Throw from the Saddle's "Target creature you
Expand Down
57 changes: 57 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19144,6 +19144,63 @@ fn instead_condition_recognizes_that_permanent_is_color() {
);
}

/// CR 608.2c + CR 115.1: a conditional counter override whose base clause
/// targeted a creature reuses that chosen object through `ParentTarget`. The
/// override must not turn its bare "it" into the resolving source or request a
/// second target. Wakandan Royal Guard and Elder Cathar cover the unrestricted
/// and controller-qualified target forms of this grammar.
#[test]
fn counter_instead_override_reuses_typed_antecedent_target() {
for effect_text in [
"Put a +1/+1 counter on target creature. If that creature is another Hero, put two +1/+1 counters on it instead.",
"Put a +1/+1 counter on target creature you control. If that creature is a Human, put two +1/+1 counters on it instead.",
] {
let ability = parse_effect_chain(effect_text, AbilityKind::Spell);
assert!(
matches!(
ability.effect.as_ref(),
Effect::PutCounter {
count: QuantityExpr::Fixed { value: 1 },
target: TargetFilter::Typed(_),
..
}
),
"the base counter instruction must retain its typed target for {effect_text:?}; got {:?}",
ability.effect
);
let override_branch = ability
.sub_ability
.as_deref()
.unwrap_or_else(|| panic!("expected counter override for {effect_text:?}"));
assert!(
matches!(
override_branch.condition.as_ref(),
Some(AbilityCondition::ConditionInstead { inner })
if matches!(inner.as_ref(), AbilityCondition::TargetMatchesFilter { .. })
),
"the override must retain a typed target-match condition for {effect_text:?}; got {:?}",
override_branch.condition
);
assert!(
matches!(
override_branch.effect.as_ref(),
Effect::PutCounter {
count: QuantityExpr::Fixed { value: 2 },
target: TargetFilter::ParentTarget,
..
}
),
"the override must put two counters on the original target for {effect_text:?}; got {:?}",
override_branch.effect
);
assert!(
!matches!(ability.effect.as_ref(), Effect::Unimplemented { .. })
&& !matches!(override_branch.effect.as_ref(), Effect::Unimplemented { .. }),
"both clauses must lower without Effect::Unimplemented for {effect_text:?}"
);
}
}

/// CR 117.1 + CR 400.7j + CR 608.2k + CR 614.1a: Stormscale Anarch class —
/// the "discard a card at random" cost paid object is checked against the
/// "multicolored" property to gate the override damage. The condition is
Expand Down
Loading
Loading