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
50 changes: 38 additions & 12 deletions crates/engine/src/parser/oracle_effect/imperative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,10 @@ fn parse_target_relative_life_change_this_turn(qty_text: &str) -> Option<Quantit
Some(QuantityExpr::Ref { qty })
}

fn parse_life_equal_quantity(after_verb_lower: &str) -> Option<QuantityExpr> {
fn parse_life_equal_quantity(
after_verb_lower: &str,
bare_card_source: Option<crate::types::ability::TrackedAnaphorSource>,
) -> Option<QuantityExpr> {
let (qty_text, _) = tag::<_, _, OracleError<'_>>("life equal to ")
.parse(after_verb_lower)
.ok()?;
Expand All @@ -796,8 +799,13 @@ fn parse_life_equal_quantity(after_verb_lower: &str) -> Option<QuantityExpr> {
if let Some(qty) = crate::parser::oracle_quantity::parse_event_context_quantity(qty_text) {
return Some(qty);
}
crate::parser::oracle_quantity::parse_quantity_ref(qty_text)
.map(|qty| QuantityExpr::Ref { qty })
if let Some(qty) = crate::parser::oracle_quantity::parse_quantity_ref(qty_text) {
return Some(QuantityExpr::Ref { qty });
}
let source = bare_card_source?;
let (rest, qty) =
nom_quantity::parse_contextual_bare_card_aggregate_ref(qty_text, source).ok()?;
rest.trim().is_empty().then_some(QuantityExpr::Ref { qty })
}

/// CR 119.3 + CR 102.1: "gain 1 life for each player" (a/an/1) → the count of
Expand Down Expand Up @@ -830,6 +838,22 @@ fn parse_gain_life_per_player(after_gain_lower: &str) -> Option<QuantityExpr> {
pub(super) fn parse_numeric_imperative_ast(
text: &str,
lower: &str,
) -> Option<NumericImperativeAst> {
parse_numeric_imperative_ast_with_bare_card_source(text, lower, None)
}

fn parse_numeric_imperative_ast_with_context(
text: &str,
lower: &str,
ctx: &ParseContext,
) -> Option<NumericImperativeAst> {
parse_numeric_imperative_ast_with_bare_card_source(text, lower, ctx.bare_card_aggregate_source)
}

fn parse_numeric_imperative_ast_with_bare_card_source(
text: &str,
lower: &str,
bare_card_source: Option<crate::types::ability::TrackedAnaphorSource>,
) -> Option<NumericImperativeAst> {
if let Some((_, rest)) = nom_on_lower(text, lower, |input| value((), tag("draw ")).parse(input))
.or_else(|| {
Expand Down Expand Up @@ -919,7 +943,7 @@ pub(super) fn parse_numeric_imperative_ast(
// CR 119.3: target-relative quantity refs ("target creature's
// power/toughness/mana value"). Mirrors LoseLife. Soul's Grace,
// Heron's Grace Champion, Lifeblood Hydra, etc.
if let Some(amount) = parse_life_equal_quantity(after_lower.as_str()) {
if let Some(amount) = parse_life_equal_quantity(after_lower.as_str(), bare_card_source) {
return Some(NumericImperativeAst::GainLife { amount });
}
// CR 119.3: "gain that much life" / "gain that many life" —
Expand Down Expand Up @@ -979,7 +1003,7 @@ pub(super) fn parse_numeric_imperative_ast(
// power/toughness/mana value", etc.) — Final Punishment, Tomb
// Blade-class drain, Genesis of the Daleks. Delegates to the
// shared `parse_quantity_ref` building block.
if let Some(amount) = parse_life_equal_quantity(after_lower.as_str()) {
if let Some(amount) = parse_life_equal_quantity(after_lower.as_str(), bare_card_source) {
return Some(NumericImperativeAst::LoseLife { amount });
}
// CR 119.3: "lose that much life" / "lose that many life" —
Expand Down Expand Up @@ -10766,9 +10790,11 @@ pub(super) fn parse_imperative_family_ast(
target: TargetFilter::Controller,
}))
}
"draw" => parse_numeric_imperative_ast(text, lower)
.map(|ast| ImperativeFamilyAst::Structured(ImperativeAst::Numeric(ast))),
"scry" | "surveil" | "mill" => parse_numeric_imperative_ast(text, lower)
_ if nom_on_lower(text, lower, |input| parse_word_bounded(input, "draw")).is_some() => {
parse_numeric_imperative_ast_with_context(text, lower, ctx)
.map(|ast| ImperativeFamilyAst::Structured(ImperativeAst::Numeric(ast)))
}
"scry" | "surveil" | "mill" => parse_numeric_imperative_ast_with_context(text, lower, ctx)
.map(|ast| ImperativeFamilyAst::Structured(ImperativeAst::Numeric(ast))),

// Targeted action verbs (CR 701)
Expand Down Expand Up @@ -11435,7 +11461,7 @@ pub(super) fn parse_imperative_family_ast(
// life-gain clauses ("gain 3 life") still fall through below.
Some(ImperativeFamilyAst::GainKeyword(effect))
} else if nom_primitives::scan_contains(lower, "life") {
parse_numeric_imperative_ast(text, lower)
parse_numeric_imperative_ast_with_context(text, lower, ctx)
.map(|ast| ImperativeFamilyAst::Structured(ImperativeAst::Numeric(ast)))
} else {
None
Expand All @@ -11457,7 +11483,7 @@ pub(super) fn parse_imperative_family_ast(
// its target for the parse_target call below.
Some(ImperativeFamilyAst::GainKeyword(effect))
} else if nom_primitives::scan_contains(lower, "life") {
parse_numeric_imperative_ast(text, lower)
parse_numeric_imperative_ast_with_context(text, lower, ctx)
.map(|ast| ImperativeFamilyAst::Structured(ImperativeAst::Numeric(ast)))
} else if !nom_primitives::scan_contains(lower, "mana") {
try_parse_gain_keyword(text).map(ImperativeFamilyAst::LoseKeyword)
Expand Down Expand Up @@ -11490,7 +11516,7 @@ pub(super) fn parse_imperative_family_ast(
"gets" | "get" => try_parse_player_counter(lower)
.or_else(|| coalesce_pump_with_modifications(text).map(ImperativeFamilyAst::GainKeyword))
.or_else(|| {
parse_numeric_imperative_ast(text, lower)
parse_numeric_imperative_ast_with_context(text, lower, ctx)
.map(|ast| ImperativeFamilyAst::Structured(ImperativeAst::Numeric(ast)))
}),

Expand Down Expand Up @@ -11531,7 +11557,7 @@ pub(super) fn parse_imperative_family_ast(
return Some(ImperativeFamilyAst::GainKeyword(effect));
}
// Numeric: contains("gain")+contains("life"), contains("gets +"), etc.
if let Some(ast) = parse_numeric_imperative_ast(text, lower) {
if let Some(ast) = parse_numeric_imperative_ast_with_context(text, lower, ctx) {
return Some(ImperativeFamilyAst::Structured(ImperativeAst::Numeric(ast)));
}
// Shuffle: "that player shuffles" / "target player shuffles" have
Expand Down
20 changes: 15 additions & 5 deletions crates/engine/src/parser/oracle_effect/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4993,7 +4993,8 @@ pub(super) fn rebind_decline_body_recipient(effect: &mut Effect) {
},
Effect::Draw { target, .. }
| Effect::Discard { target, .. }
| Effect::Mill { target, .. } => rebind(target),
| Effect::Mill { target, .. }
| Effect::DealDamage { target, .. } => rebind(target),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Effect::Token { owner, .. } => rebind(owner),
_ => {}
}
Expand Down Expand Up @@ -7921,6 +7922,13 @@ pub(super) fn try_parse_damage(lower: &str, text: &str, ctx: &mut ParseContext)
Some(effect)
}

/// CR 608.2c: Bind a bare "those cards" aggregate only to its typed chain antecedent.
fn parse_contextual_bare_card_aggregate(text: &str, ctx: &ParseContext) -> Option<QuantityExpr> {
let source = ctx.bare_card_aggregate_source?;
let (rest, qty) = nom_quantity::parse_contextual_bare_card_aggregate_ref(text, source).ok()?;
rest.trim().is_empty().then_some(QuantityExpr::Ref { qty })
}

/// Parse damage effects, returning both the Effect and `parse_target`'s unconsumed
/// remainder. The remainder is the compound boundary oracle — if it starts with
/// `" and "`, the caller can chain the trailing clause as a sub_ability.
Expand Down Expand Up @@ -8037,7 +8045,8 @@ pub(super) fn try_parse_damage_with_remainder<'a>(
} else {
parse_cda_quantity_with_context(amount_phrase, ctx)
}
});
})
.or_else(|| parse_contextual_bare_card_aggregate(amount_phrase, ctx));
if let Some(qty) = qty {
// Route based on target phrase
if target_phrase == "itself" {
Expand Down Expand Up @@ -8221,10 +8230,11 @@ pub(super) fn try_parse_damage_with_remainder<'a>(
// CDA quantity parser (`the number of … you control`, `your life total`,
// …). Without this fallback the phrase degrades to a raw `Variable`, which
// resolves to 0 at runtime — the damage silently no-ops.
let qty =
crate::parser::oracle_quantity::parse_event_context_quantity(qty_text).or_else(|| {
let qty = crate::parser::oracle_quantity::parse_event_context_quantity(qty_text)
.or_else(|| {
crate::parser::oracle_quantity::parse_cda_quantity_with_context(qty_text, ctx)
});
})
.or_else(|| parse_contextual_bare_card_aggregate(qty_text, ctx));
let qty = match qty {
Some(qty) => qty,
// CR 120.1 + CR 202.3: The typed quantity parsers declined this
Expand Down
90 changes: 90 additions & 0 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27627,6 +27627,72 @@ fn publishes_aggregate_set_from_resolution(effect: &Effect) -> bool {
publishes_tracked_set_from_resolution(effect) || matches!(effect, Effect::Sacrifice { .. })
}

/// CR 608.2c: Classification used only for the bare card-set surface "those
/// cards". Mill and discard establish the cards that surface names. Any nearer
/// producer from the existing aggregate-set authority blocks an older mill or
/// discard without changing the broad binding rules for other anaphora.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum BareCardAggregatePublisher {
ChainSetCompatible,
EventFallbackBarrier,
TerminalUnsupported,
}

pub(super) fn classify_bare_card_aggregate_publisher(
effect: &Effect,
) -> Option<BareCardAggregatePublisher> {
if matches!(
effect,
Effect::Mill { .. } | Effect::Discard { .. } | Effect::DiscardCard { .. }
) {
Some(BareCardAggregatePublisher::ChainSetCompatible)
} else if is_token_creating_effect(effect) {
Some(BareCardAggregatePublisher::EventFallbackBarrier)
} else if publishes_aggregate_set_from_resolution(effect)
|| matches!(
effect,
Effect::TargetOnly { .. } | Effect::ChooseObjectsIntoTrackedSet { .. }
)
{
Some(BareCardAggregatePublisher::TerminalUnsupported)
} else {
None
}
}

fn classify_latest_bare_card_publisher_in_ability(
def: &AbilityDefinition,
) -> Option<BareCardAggregatePublisher> {
let primary = def
.sub_ability
.as_deref()
.and_then(classify_latest_bare_card_publisher_in_ability)
.or_else(|| classify_bare_card_aggregate_publisher(&def.effect));
let Some(alternate) = def.else_ability.as_deref() else {
return primary;
};
match (
primary,
classify_latest_bare_card_publisher_in_ability(alternate),
) {
(None, None) => None,
(Some(a), Some(b)) if a == b => Some(a),
(Some(_), None) | (None, Some(_)) | (Some(_), Some(_)) => {
Some(BareCardAggregatePublisher::TerminalUnsupported)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

fn classify_latest_bare_card_publisher_in_clause(
clause: &ParsedEffectClause,
) -> Option<BareCardAggregatePublisher> {
clause
.sub_ability
.as_deref()
.and_then(classify_latest_bare_card_publisher_in_ability)
.or_else(|| classify_bare_card_aggregate_publisher(&clause.effect))
}

/// CR 608.2c: Re-anchor a batched set-anaphor aggregate to the CHAIN-published
/// set.
///
Expand Down Expand Up @@ -28772,6 +28838,16 @@ fn rewrite_player_scope_refs(def: &mut AbilityDefinition) {
}

each_quantity_expr_mut(&mut def.effect, &mut rewrite_quantity_expr);
// CR 109.5: Explicit All scopes retain Controller for their ScopedPlayer rewrite;
// inherited opponent-decline nodes have no local scope and still rebind here.
if !matches!(def.player_scope, Some(PlayerFilter::All))
&& def
.condition
.as_ref()
.is_some_and(AbilityCondition::is_not_optional_effect_performed)
{
rebind_decline_body_recipient(&mut def.effect);
}
// CR 608.2 + CR 109.5: Rebind actor-default `You` controllers to
// `ScopedPlayer` for each-*player* iterations only. Each-opponent scopes
// keep `You` so optional opponent-choice sacrifices ("permanent of their
Expand Down Expand Up @@ -33372,6 +33448,19 @@ pub(crate) fn parse_effect_chain_ir(
sequence::parse_token_source_power_toughness_followup(next_text)
})
.map(|(power, toughness)| TokenPtFollowup::PowerToughness { power, toughness });
let nearest_bare_card_publisher = builder
.clauses()
.iter()
.rev()
.find_map(|clause| classify_latest_bare_card_publisher_in_clause(&clause.parsed));
let bare_card_aggregate_source = match nearest_bare_card_publisher {
Some(BareCardAggregatePublisher::ChainSetCompatible) => {
Some(TrackedAnaphorSource::ChainSet)
}
Some(BareCardAggregatePublisher::EventFallbackBarrier)
| Some(BareCardAggregatePublisher::TerminalUnsupported)
| None => None,
};
let mut chunk_ctx = ParseContext {
subject: chunk_subject,
card_name: ctx.card_name.clone(),
Expand Down Expand Up @@ -33510,6 +33599,7 @@ pub(crate) fn parse_effect_chain_ir(
// player" on Ghyrson) bind to the triggering event instead of being
// reparsed as ordinary target phrases.
in_trigger: ctx.in_trigger,
bare_card_aggregate_source,
// CR 701.42a: propagate the staged meld partner so a reflexive
// "exile them, then meld them into R" sub-clause parsed inside this
// chunk (Vanille's "If you do, …" body, which chunks to a single
Expand Down
Loading
Loading