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
27 changes: 27 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13641,6 +13641,33 @@ fn effect_create_colored_token() {
));
}

/// CR 111.4: Kher Keep's activated ability sets the created token's literal
/// name. This exercises the public `parse_oracle_text` entry point, including
/// self-reference normalization before token-description lowering.
#[test]
fn kher_keep_preserves_literal_created_token_name() {
let parsed = parse_oracle_text(
"{1}{R}, {T}: Create a 0/1 red Kobold creature token named Kobolds of Kher Keep.",
"Kher Keep",
&[],
&["Land".to_string()],
&[],
);

assert_eq!(
parsed.abilities.len(),
1,
"Kher Keep must lower its activated ability: {parsed:#?}"
);
let Effect::Token { name, .. } = parsed.abilities[0].effect.as_ref() else {
panic!(
"Kher Keep must lower its activated ability to Token, got {:?}",
parsed.abilities[0].effect
);
};
assert_eq!(name, "Kobolds of Kher Keep");
}

#[test]
fn effect_chain_create_distinct_token_sequence_preserves_second_token() {
let def = parse_effect_chain(
Expand Down
243 changes: 219 additions & 24 deletions crates/engine/src/parser/oracle_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1722,7 +1722,23 @@ fn unmask_card_name_keyword_action(text: String, originals: &[String]) -> String
result
}

fn parse_card_named_literal_prefix(input: &str) -> OracleResult<'_, usize> {
/// Which flavour of literal-name span a `"… named <X>"` prefix opens.
///
/// A **token**'s name is followed by the inline clauses
/// that define the token in place ("… with \"…\"", "… that's attacking",
/// "… attached to …"), so its span ends at those clauses. A **card filter**'s
/// name is not defined in place, and real card names contain exactly those
/// words ("Once More with Feeling", "All That Glitters") — ending its span
/// there would truncate the name. The two therefore need different boundaries,
/// which is why the prefix parser reports which one it matched instead of
/// letting the caller guess from the text.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum NamedLiteralKind {
CardFilter,
Token,
}

fn parse_card_named_literal_prefix(input: &str) -> OracleResult<'_, (usize, NamedLiteralKind)> {
alt((
// CR 201.2a + CR 201.5c: a meld RESULT name ("meld them into Titania,
// Gaea Incarnate") is a distinct object's literal name, not a self-ref to
Expand All @@ -1736,21 +1752,79 @@ fn parse_card_named_literal_prefix(input: &str) -> OracleResult<'_, usize> {
// the instigator (Gisela → Brisela) are already clean; the mask is a no-op
// for them. Improvement-only for the activated melds' result string
// (Urza, Lord Protector) — their activated-meld runtime path is unchanged.
value("meld them into ".len(), tag("meld them into ")),
value("permanents named ".len(), tag("permanents named ")),
value("permanent named ".len(), tag("permanent named ")),
value("creatures named ".len(), tag("creatures named ")),
value("creature named ".len(), tag("creature named ")),
value("artifacts named ".len(), tag("artifacts named ")),
value("artifact named ".len(), tag("artifact named ")),
value("enchantments named ".len(), tag("enchantments named ")),
value("enchantment named ".len(), tag("enchantment named ")),
value("lands named ".len(), tag("lands named ")),
value("land named ".len(), tag("land named ")),
value("spells named ".len(), tag("spells named ")),
value("spell named ".len(), tag("spell named ")),
value("cards named ".len(), tag("cards named ")),
value("card named ".len(), tag("card named ")),
value(
("meld them into ".len(), NamedLiteralKind::CardFilter),
tag("meld them into "),
),
// The token-creation template writes the noun "token" immediately before
// "named", whatever type words precede it ("create a
// legendary black Aura Curse enchantment token named Selenia's Curse").
// Anchoring on that noun covers the whole class in one rule instead of
// enumerating every type-word combination that can lead up to it.
value(
("tokens named ".len(), NamedLiteralKind::Token),
tag("tokens named "),
),
value(
("token named ".len(), NamedLiteralKind::Token),
tag("token named "),
),
value(
("permanents named ".len(), NamedLiteralKind::CardFilter),
tag("permanents named "),
),
value(
("permanent named ".len(), NamedLiteralKind::CardFilter),
tag("permanent named "),
),
value(
("creatures named ".len(), NamedLiteralKind::CardFilter),
tag("creatures named "),
),
value(
("creature named ".len(), NamedLiteralKind::CardFilter),
tag("creature named "),
),
value(
("artifacts named ".len(), NamedLiteralKind::CardFilter),
tag("artifacts named "),
),
value(
("artifact named ".len(), NamedLiteralKind::CardFilter),
tag("artifact named "),
),
value(
("enchantments named ".len(), NamedLiteralKind::CardFilter),
tag("enchantments named "),
),
value(
("enchantment named ".len(), NamedLiteralKind::CardFilter),
tag("enchantment named "),
),
value(
("lands named ".len(), NamedLiteralKind::CardFilter),
tag("lands named "),
),
value(
("land named ".len(), NamedLiteralKind::CardFilter),
tag("land named "),
),
value(
("spells named ".len(), NamedLiteralKind::CardFilter),
tag("spells named "),
),
value(
("spell named ".len(), NamedLiteralKind::CardFilter),
tag("spell named "),
),
value(
("cards named ".len(), NamedLiteralKind::CardFilter),
tag("cards named "),
),
value(
("card named ".len(), NamedLiteralKind::CardFilter),
tag("card named "),
),
))
.parse(input)
}
Expand Down Expand Up @@ -1884,7 +1958,37 @@ fn parse_card_named_clause_boundary(input: &str) -> OracleResult<'_, ()> {
value((), alt((tag("."), tag(":")))).parse(input)
}

fn parse_card_named_literal_boundary(input: &str) -> OracleResult<'_, ()> {
/// The clauses that define a freshly created token stand right behind its name
/// and are not part of it. Terminating the token's literal span here
/// keeps the mask off the surrounding text, so a quoted granted body still gets
/// its own self-reference normalization.
///
/// Deliberately NOT applied to card-filter spans (see [`NamedLiteralKind`]): a
/// bare comma is likewise excluded, because token names carry one
/// ("Icingdeath, Frost Tongue").
fn parse_token_named_literal_boundary(input: &str) -> OracleResult<'_, ()> {
let (input, _) = space1::<_, OracleError<'_>>(input)?;
let (input, _) = alt((
value((), (tag("with"), space1)),
value((), (tag("attached"), space1)),
value(
(),
(tag("that"), alt((value((), space1), value((), tag("'s "))))),
),
))
.parse(input)?;
Ok((input, ()))
}

fn parse_card_named_literal_boundary(input: &str, kind: NamedLiteralKind) -> OracleResult<'_, ()> {
match kind {
NamedLiteralKind::Token => {
if let Ok(found) = parse_token_named_literal_boundary(input) {
return Ok(found);
}
}
NamedLiteralKind::CardFilter => {}
}
alt((
parse_card_named_list_boundary,
parse_card_named_zone_boundary,
Expand All @@ -1896,7 +2000,7 @@ fn parse_card_named_literal_boundary(input: &str) -> OracleResult<'_, ()> {
.parse(input)
}

fn next_card_named_literal_prefix(lower: &str) -> Option<(usize, usize)> {
fn next_card_named_literal_prefix(lower: &str) -> Option<(usize, usize, NamedLiteralKind)> {
lower.char_indices().find_map(|(idx, _)| {
let is_word_boundary = idx == 0
|| lower[..idx]
Expand All @@ -1906,15 +2010,15 @@ fn next_card_named_literal_prefix(lower: &str) -> Option<(usize, usize)> {
is_word_boundary
.then(|| parse_card_named_literal_prefix(&lower[idx..]).ok())
.flatten()
.map(|(_, prefix_len)| (idx, prefix_len))
.map(|(_, (prefix_len, kind))| (idx, prefix_len, kind))
})
}

fn card_named_literal_span_len(lower: &str) -> usize {
fn card_named_literal_span_len(lower: &str, kind: NamedLiteralKind) -> usize {
lower
.char_indices()
.find_map(|(idx, _)| {
parse_card_named_literal_boundary(&lower[idx..])
parse_card_named_literal_boundary(&lower[idx..], kind)
.is_ok()
.then_some(idx)
})
Expand All @@ -1926,16 +2030,23 @@ fn card_named_literal_span_len(lower: &str) -> usize {
/// while `normalize_card_name_refs` runs so first-word fallback cannot rewrite
/// cards like Emerald Collector's "Mox Emerald" into "Mox ~" or Kookus's
/// "Keeper of Kookus" into "Keeper of ~".
///
/// CR 111.4: a spell or ability that creates a token sets its name. Token names
/// are therefore masked the same way — the name a card gives the token is a
/// literal name, never a reference back to the creating card, even when it
/// embeds the creator's own name (Selenia, the
/// Cursed Heart → "Selenia's Curse"; Volo, Itinerant Scholar → "Volo's
/// Journal"). Their spans end at [`parse_token_named_literal_boundary`].
fn mask_card_named_literal_spans(text: &str) -> (String, Vec<String>) {
let lower = text.to_ascii_lowercase();
let mut masked = String::with_capacity(text.len());
let mut originals = Vec::new();
let mut rest = text;
let mut lower_rest = lower.as_str();

while let Some((idx, prefix_len)) = next_card_named_literal_prefix(lower_rest) {
while let Some((idx, prefix_len, kind)) = next_card_named_literal_prefix(lower_rest) {
let name_start = idx + prefix_len;
let name_len = card_named_literal_span_len(&lower_rest[name_start..]);
let name_len = card_named_literal_span_len(&lower_rest[name_start..], kind);
if name_len == 0 {
masked.push_str(&rest[..name_start]);
rest = &rest[name_start..];
Expand Down Expand Up @@ -2697,7 +2808,91 @@ mod tests {
assert!(next_card_named_literal_prefix("nazgûlcard named mox emerald").is_none());
assert_eq!(
next_card_named_literal_prefix("nazgûl card named mox emerald"),
Some(("nazgûl ".len(), "card named ".len()))
Some((
"nazgûl ".len(),
"card named ".len(),
NamedLiteralKind::CardFilter
))
);
}

#[test]
fn token_named_literal_prefix_classifies_plural_as_token() {
assert_eq!(
next_card_named_literal_prefix("create two tokens named kobolds of kher keep"),
Some((
"create two ".len(),
"tokens named ".len(),
NamedLiteralKind::Token
))
);
}

#[test]
fn normalize_token_named_literal_keeps_creator_name_inside_token_name() {
// CR 111.4: Selenia, the Cursed Heart names the token it creates
// "Selenia's Curse". That is the token's own literal name, not a
// self-reference — without the mask the comma-short-name strategy folds
// it to "~'s Curse", the trailing `~` → card-name expansion turns that
// into "Selenia, the Cursed Heart's Curse", and the token-name clause
// parser then truncates at the injected comma to a bare "Selenia".
assert_eq!(
normalize_card_name_refs(
"When Selenia dies, create a legendary black Aura Curse enchantment token named Selenia's Curse attached to target opponent.",
"Selenia, the Cursed Heart",
),
"When ~ dies, create a legendary black Aura Curse enchantment token named Selenia's Curse attached to target opponent."
);
}

#[test]
fn normalize_token_named_literal_keeps_full_card_name_inside_token_name() {
// CR 111.4: Kher Keep's token is literally named "Kobolds of Kher
// Keep" — the creator's *full* name inside the token's name. The
// full-name strategy (a different branch than the comma-short one
// above) must leave it alone.
assert_eq!(
normalize_card_name_refs(
"{1}{R}, {T}: Create a 0/1 red Kobold creature token named Kobolds of Kher Keep.",
"Kher Keep",
),
"{1}{R}, {T}: Create a 0/1 red Kobold creature token named Kobolds of Kher Keep."
);
}

#[test]
fn normalize_token_named_literal_span_ends_at_the_defining_with_clause() {
// The span covers the name only. Ajani's "with \"…\"" clause
// stays outside it — proven by "this token" inside that clause still
// folding to `~` (it would stay literal if the span had swallowed the
// clause), while "Ajani's Pridemate" itself stays literal and line 3's
// short name still normalizes.
assert_eq!(
normalize_card_name_refs(
"[+1]: You gain life equal to the number of creatures you control plus the number of planeswalkers you control.\n[−2]: Create a 2/2 white Cat Soldier creature token named Ajani's Pridemate with \"Whenever you gain life, put a +1/+1 counter on this token.\"\n[0]: If you have at least 15 life more than your starting life total, exile Ajani and each artifact and creature your opponents control.",
"Ajani, Strength of the Pride",
),
"[+1]: You gain life equal to the number of creatures you control plus the number of planeswalkers you control.\n[−2]: Create a 2/2 white Cat Soldier creature token named Ajani's Pridemate with \"Whenever you gain life, put a +1/+1 counter on ~.\"\n[0]: If you have at least 15 life more than your starting life total, exile ~ and each artifact and creature your opponents control."
);
}

#[test]
fn normalize_token_boundaries_do_not_reach_card_filter_spans() {
// Counter-direction: "Once More with Feeling" is a CARD name that
// contains "with". Applying the token boundaries here would truncate the
// masked span to "Once More" and expose the rest — the reason
// [`NamedLiteralKind`] exists rather than one shared boundary set.
//
// This row does NOT discriminate: it is green with and without the
// token-prefix entries, because it pins the direction that must stay
// unchanged. It guards a later widening of the boundary set, not the
// bug this change fixes.
assert_eq!(
normalize_card_name_refs(
"A deck can have only one card named Once More with Feeling.",
"Once More with Feeling",
),
"A deck can have only one card named Once More with Feeling."
);
}

Expand Down
Loading