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
19 changes: 13 additions & 6 deletions crates/engine/src/parser/oracle_effect/imperative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8985,7 +8985,7 @@ pub(super) fn parse_exile_ast(
// Excise the consumed clause so the debug-only compound-remainder assert
// below does not flag it.
let rem_lower = rem.to_ascii_lowercase();
let (mut enter_with_counters, counters_offset) =
let (mut enter_with_counters, counters_span) =
super::parse_with_counters_suffix_spanned(&rem_lower);
// CR 122.2 + CR 702.62a: Adopt the counters lifted off a counterless-origin
// descriptive target above (Doom's Time Platform) when the post-target
Expand All @@ -9004,7 +9004,7 @@ pub(super) fn parse_exile_ast(
// carry a "with N counters" FILTER — that reading only applies to
// descriptive targets like "exile each creature with a +1/+1 counter on
// it"), recover the enter-with-counters suffix from the full clause. The
// `rem` is already empty in this case, so `counters_offset` stays `None`.
// `rem` is already empty in this case, so `counters_span` stays `None`.
if enter_with_counters.is_empty()
&& matches!(
parsed_target,
Expand All @@ -9014,12 +9014,19 @@ pub(super) fn parse_exile_ast(
let rest_lower_full = rest_text.to_ascii_lowercase();
enter_with_counters = super::parse_with_counters_suffix(&rest_lower_full);
}
let _rem = match counters_offset {
Some(off) => &rem[..off],
None => rem,
// Excise ONLY the counter clause's span and check BOTH sides. Truncating at
// the span's start (as this did before) hid any compound instruction printed
// after the rider from the very assert whose job is to catch silent
// remainder drops.
let (_rem_head, _rem_tail) = match &counters_span {
Some(span) => (&rem[..span.start], &rem[span.end..]),
None => (rem, ""),
};
#[cfg(debug_assertions)]
assert_no_compound_remainder(_rem, text);
{
assert_no_compound_remainder(_rem_head, text);
assert_no_compound_remainder(_rem_tail, text);
}
// CR 701.5a: "exile target spell" must constrain targeting to the stack,
// mirroring parse_counter_ast at line 1218-1219.
let target = if nom_primitives::scan_contains(rest_lower, "spell") {
Expand Down
425 changes: 315 additions & 110 deletions crates/engine/src/parser/oracle_effect/lower.rs

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35672,9 +35672,15 @@ pub(super) fn split_counterless_enter_counters(
) {
return (clause, Vec::new());
}
let (counters, offset) = parse_with_counters_suffix_spanned(&lower);
match offset {
Some(off) if !counters.is_empty() => (clause[..off].trim_end(), counters),
let (counters, span) = parse_with_counters_suffix_spanned(&lower);
match span {
// Truncating at the span's START (rather than excising `start..end`) is
// deliberate here and loses nothing: `clause` is the TARGET text of an
// exile whose descriptive target was drawn from a counterless origin
// zone, and the counter rider is the last thing printed in it. Anything
// after the rider would belong to the following instruction, which the
// clause splitter has already peeled off before this runs.
Some(span) if !counters.is_empty() => (clause[..span.start].trim_end(), counters),
_ => (clause, Vec::new()),
}
}
Expand Down
101 changes: 101 additions & 0 deletions crates/engine/src/parser/oracle_effect/sequence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2765,6 +2765,42 @@ fn starts_bare_and_clause_lower(s: &str) -> bool {
value((), tag("you search ")),
value((), tag("you surveil ")),
value((), tag("you get ")),
// CR 725.1: "There is no monarch in a game until an effect instructs a
// player to become the monarch" — becoming the monarch is its own
// instruction, never a noun-phrase continuation of the conjunct before
// it. CR 608.2c: the controller follows the printed instructions "in
// the order written", so a trailing "… and you become the monarch" is
// the NEXT instruction and must reach the clause dispatcher on its own.
//
// Exactly two lines in the whole corpus contain " and you become " —
// both were broken, in different ways:
// Heart-Shaped Herb: "…with three +1/+1 counters on it AND YOU BECOME
// THE MONARCH" — dropped SILENTLY (reported supported, zero gaps).
// It failed at TWO seams: the return-destination counter suffix in
// `strip_return_destination_ext_with_remainder` (lower.rs) used to
// truncate its remainder at the counter clause's START offset,
// discarding the tail before any guard could see it (that seam now
// CONSUMES the clause as a leading entry rider, so the tail
// survives), and this splitter had no arm to peel the tail into its
// own chunk once it did.
// Fall from Favor: "tap enchanted creature AND YOU BECOME THE
// MONARCH" — isolated by `try_split_targeted_compound` (mod.rs) but
// dispatched through `parse_imperative_effect`, which never tries
// the subject-predicate path for a bare "you" subject, so it
// surfaced as `Unimplemented { name: "you" }`.
// Splitting at the chunk level runs BEFORE both of those seams, so this
// one arm recovers both cards.
//
// The tag is the SUBJECT+VERB boundary, not the designation: "become"
// is a registered `PREDICATE_VERBS` member (subject.rs) and
// `build_become_clause` (subject.rs) is the single authority that
// adjudicates WHICH become (monarch / life total / day-night / color /
// animation). Matching "you become the monarch" here would put
// card-specific knowledge in the sentence-chunking layer. Mirrors the
// `it becomes ` arm below — same predicate, different subject. A
// blanket "you <PREDICATE_VERB>" rule is NOT safe: " and you control"
// (22 corpus lines) is a relative clause, never a clause start.
value((), tag("you become ")),
value((), tag("you may ")),
// CR 614.1b + CR 603.7a: "Effects that use the word 'skip' are
// replacement effects" — a skip is its own instruction, never a
Expand Down Expand Up @@ -12407,6 +12443,71 @@ mod tests {
));
}

/// CR 725.1 + CR 608.2c: "… and you become <designation>" is a subject +
/// predicate clause, never a noun-phrase continuation. The splitter arm is
/// deliberately VERB-level (`"you become "`), not designation-level, so
/// `build_become_clause` (subject.rs) stays the single authority over which
/// become is meant.
#[test]
fn bare_and_clause_starts_on_you_become_subject_predicate() {
// The two real corpus lines this fixes (Heart-Shaped Herb, Fall from
// Favor) both use the monarch designation.
assert!(starts_bare_and_clause("you become the monarch"));
assert!(starts_bare_and_clause("You become the Monarch"));

// Generalization guard: the arm is designation-agnostic, so a future
// non-monarch `become` conjunct is carried too. These are SYNTHETIC
// inputs to the predicate — both phrases are real Oracle text, but
// neither currently occurs after a bare " and ", so this is not an
// end-to-end reachability claim. Downstream, `build_become_clause`
// declines the monarch arm and falls through to the animation path,
// which is the honest-defer route.
assert!(starts_bare_and_clause("you become the starting player"));
assert!(starts_bare_and_clause(
"you become a card until you leave your library or that library is shuffled"
));

// Negative: " and you control …" is a RELATIVE clause (22 corpus
// lines), never a clause start. A blanket "you <PREDICATE_VERB>" rule
// would have split these and changed unrelated cards.
assert!(!starts_bare_and_clause("you control"));
assert!(!starts_bare_and_clause("you control a Swamp"));
assert!(!starts_bare_and_clause(
"you control a legendary creature or planeswalker"
));
}

/// CR 725.1 + CR 608.2c: the bare-and split must peel the monarch conjunct
/// off Heart-Shaped Herb's activated ability into its OWN chunk, and
/// `push_clause_chunk`'s `trim_end_matches(['.', ','])` must strip the
/// sentence-final period. The period is load-bearing: `build_become_clause`
/// gates on the exact match `become_text.eq_ignore_ascii_case("the
/// monarch")` (subject.rs), which a surviving "." would defeat, silently
/// falling through to the animation path.
#[test]
fn you_become_monarch_conjunct_splits_without_trailing_period() {
// Verbatim Oracle text (data/mtgjson/AtomicCards.json), effect body of
// the "{2}, {T}, Sacrifice this artifact:" ability.
let chunks = clause_texts(
"return that card to the battlefield under its owner's control with three +1/+1 counters on it and you become the monarch.",
);
assert_eq!(
chunks.last().map(String::as_str),
Some("you become the monarch"),
"monarch conjunct must be its own chunk with no trailing period, got {chunks:?}"
);
// Paired positive reach-guard: the leading return clause must survive
// intact, so a passing split assertion cannot be an artifact of the
// sentence being mangled.
assert_eq!(
chunks.first().map(String::as_str),
Some(
"return that card to the battlefield under its owner's control with three +1/+1 counters on it"
),
"return clause must remain whole, got {chunks:?}"
);
}

/// CR 608.2c: Anaphoric back-reference conjuncts. Nalia de'Arnise's third
/// ability is the canonical exemplar — "put a +1/+1 counter on each
/// creature you control and those creatures gain deathtouch until end of
Expand Down
215 changes: 215 additions & 0 deletions crates/engine/src/parser/oracle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13073,6 +13073,221 @@ fn you_become_the_monarch_subject() {
);
}

/// Walks an ability chain looking for any clause that failed closed to
/// [`Effect::Unimplemented`]. Used by the monarch conjunct tests as a
/// non-vacuous guard: recovering a clause is only a fix if it produces a real
/// typed effect rather than a differently-shaped gap.
///
/// Traverses every nested-definition field on `AbilityDefinition` —
/// `sub_ability`, `else_ability` (CR 608.2c "Otherwise, …" branch) and
/// `mode_abilities` (CR 700.2 modal) — mirroring
/// `AbilityDefinition::normalize_parsed_replacement_flags` (types/ability.rs),
/// the existing authority for "walk this definition's nested chain". Partial
/// traversal would reintroduce the exact vacuous-negative class this guard
/// exists to prevent: a `you become <designation>` conjunct that landed in an
/// unvisited branch still carrying `Effect::Unimplemented` would pass
/// silently. Neither Heart-Shaped Herb nor Fall from Favor produces an
/// else-branch or modes today, so this is forward protection, not a live fix.
fn monarch_chain_has_unimplemented(def: &AbilityDefinition) -> bool {
if matches!(*def.effect, Effect::Unimplemented { .. }) {
return true;
}
def.sub_ability
.as_deref()
.is_some_and(monarch_chain_has_unimplemented)
|| def
.else_ability
.as_deref()
.is_some_and(monarch_chain_has_unimplemented)
|| def
.mode_abilities
.iter()
.any(monarch_chain_has_unimplemented)
}

/// CR 725.1 + CR 608.2c: Heart-Shaped Herb's activated ability ends with
/// "… with three +1/+1 counters on it and you become the monarch". The trailing
/// conjunct was dropped SILENTLY — the card reported as fully supported with
/// zero gaps while discarding a printed instruction — because both seams it
/// crosses were broken: `strip_return_destination_ext_with_remainder` (lower.rs)
/// truncated its remainder at the counter clause's start offset, and the
/// chunk-level bare-and splitter had no `"you become "` arm to peel the tail
/// into its own clause even once it survived.
///
/// The monarch grant must land NESTED under the `EffectOutcome`-gated
/// `ChangeZone`, not as a sibling of the `Sacrifice`: CR 608.2c means
/// declining "You may sacrifice a creature" must skip the monarch grant too.
/// A `SequentialSibling` placement would wrongly hand out the monarch on
/// decline, so the link is the load-bearing assertion here.
#[test]
fn heart_shaped_herb_activated_ability_grants_monarch_as_continuation() {
use crate::parser::oracle_effect::parse_effect_chain;
use crate::types::ability::{AbilityCondition, AbilityKind, SubAbilityLink};

// Verbatim Oracle text (data/mtgjson/AtomicCards.json), effect body of the
// "{2}, {T}, Sacrifice this artifact:" ability.
let def = parse_effect_chain(
"You may sacrifice a creature. If you do, return that card to the battlefield under its owner's control with three +1/+1 counters on it and you become the monarch.",
AbilityKind::Activated,
);

// Paired positive reach-guard: the leading sacrifice and the gated return
// must both still be present, so a passing monarch assertion cannot be an
// artifact of the sentence being re-parsed into something else.
assert!(
matches!(*def.effect, Effect::Sacrifice { .. }),
"head effect must remain Sacrifice, got {:?}",
def.effect,
);
let change_zone = def
.sub_ability
.as_ref()
.expect("sacrifice must carry the gated return as its sub-ability");
assert!(
matches!(
*change_zone.effect,
Effect::ChangeZone {
destination: Zone::Battlefield,
..
}
),
"gated sub must remain the battlefield return, got {:?}",
change_zone.effect,
);
assert_eq!(
change_zone.condition,
Some(AbilityCondition::EffectOutcome {
signal: crate::types::ability::EffectOutcomeSignal::OptionalEffectPerformed,
}),
"the return must stay gated on the optional sacrifice being performed"
);

// The fix: the monarch conjunct is recovered as the return's continuation.
let monarch = change_zone
.sub_ability
.as_ref()
.expect("the 'and you become the monarch' conjunct must be recovered");
assert!(
matches!(*monarch.effect, Effect::BecomeMonarch),
"expected BecomeMonarch, got {:?}",
monarch.effect,
);
// CR 608.2c: a ContinuationStep under the gated return is skipped when the
// optional sacrifice is declined. This is the assertion that flips if the
// splitter arm is reverted (the node disappears entirely).
assert_eq!(
monarch.sub_link,
SubAbilityLink::ContinuationStep,
"monarch grant must be a continuation of the gated return, not an \
independent sibling — a sibling would grant the monarch even when the \
optional sacrifice is declined"
);
assert!(
!monarch_chain_has_unimplemented(&def),
"no clause may fail closed to Unimplemented"
);
}

/// CR 725.1 + CR 608.2c: Fall from Favor — "When this Aura enters, tap
/// enchanted creature and you become the monarch." Before the splitter arm the
/// conjunct was isolated by `try_split_targeted_compound` (mod.rs) but
/// dispatched through `parse_imperative_effect`, which never tries the
/// subject-predicate path for a bare "you" subject, so it surfaced as
/// `Effect::Unimplemented { name: "you" }` and the card was reported as
/// unsupported. Splitting at the chunk level runs first, so the conjunct
/// reaches `try_parse_subject_become_clause` → `build_become_clause`.
#[test]
fn fall_from_favor_trigger_body_grants_monarch_not_unimplemented() {
use crate::parser::oracle_effect::parse_effect_chain;
use crate::types::ability::AbilityKind;

// Verbatim Oracle text (data/mtgjson/AtomicCards.json), trigger body.
let def = parse_effect_chain(
"tap enchanted creature and you become the monarch",
AbilityKind::Spell,
);

// Paired positive reach-guard: the tap clause must survive. A chain that
// lost the tap half must not pass this test.
assert!(
matches!(
*def.effect,
Effect::SetTapState {
state: TapStateChange::Tap,
..
}
),
"tap clause must remain intact, got {:?}",
def.effect,
);
let monarch = def
.sub_ability
.as_ref()
.expect("the 'and you become the monarch' conjunct must be recovered");
assert!(
matches!(*monarch.effect, Effect::BecomeMonarch),
"expected BecomeMonarch, got {:?}",
monarch.effect,
);
assert!(
!monarch_chain_has_unimplemented(&def),
"the bare 'you' subject must no longer fail closed to Unimplemented"
);
}

/// CR 608.2c: the `sub_link` on a recovered `you become …` conjunct comes from
/// the printed BOUNDARY, not from the verb. A sentence boundary must yield
/// `SequentialSibling` (the monarch grant is then independent of the preceding
/// instruction), while the bare-and conjunct above yields `ContinuationStep`.
#[test]
fn you_become_monarch_sub_link_tracks_boundary_not_verb() {
use crate::parser::oracle_effect::parse_effect_chain;
use crate::types::ability::{AbilityKind, SubAbilityLink};

let sentence = parse_effect_chain(
"Tap enchanted creature. You become the monarch.",
AbilityKind::Spell,
);
let monarch = sentence
.sub_ability
.as_ref()
.expect("sentence-boundary monarch clause must be present");
assert!(
matches!(*monarch.effect, Effect::BecomeMonarch),
"expected BecomeMonarch, got {:?}",
monarch.effect,
);
assert_eq!(
monarch.sub_link,
SubAbilityLink::SequentialSibling,
"a sentence boundary must produce an independent sibling"
);

// Hostile fixture: swap the become-verb conjunct for an already-supported
// `you gain ` conjunct at the SAME bare-and boundary. The link must be
// identical, proving it is derived from the boundary rather than the verb.
let gain = parse_effect_chain(
"tap enchanted creature and you gain 2 life",
AbilityKind::Spell,
);
let gain_sub = gain
.sub_ability
.as_ref()
.expect("bare-and 'you gain' conjunct must be present");
let become_chain = parse_effect_chain(
"tap enchanted creature and you become the monarch",
AbilityKind::Spell,
);
let become_sub = become_chain
.sub_ability
.as_ref()
.expect("bare-and 'you become' conjunct must be present");
assert_eq!(
become_sub.sub_link, gain_sub.sub_link,
"the bare-and boundary must produce the same link for both verbs"
);
}

// ── Coverage batch: prevent damage ────────────────────────────────

#[test]
Expand Down
Loading
Loading