Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
385 changes: 376 additions & 9 deletions crates/engine/src/parser/oracle_effect/lower.rs

Large diffs are not rendered by default.

59 changes: 57 additions & 2 deletions crates/engine/src/parser/oracle_effect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14742,6 +14742,18 @@ fn try_parse_for_each_counter_kind_adjust_target(text: &str) -> Option<ParsedEff
}

fn lower_imperative_clause(text: &str, ctx: &mut ParseContext) -> ParsedEffectClause {
// CR 601.2c: the announced target count for a "… to each of ⟨N⟩ …" head is a
// property of THIS clause only. `parse_imperative_effect` is also reached
// from sites that never consume this field (the shared-`ctx`
// `try_parse_reanimate_self_and_target` sub-parse) and from speculative
// sub-parses that mutate `ctx` and then discard their result (the
// radiance / multi-target-chain / general damage-compound splitters). Reset
// here — the very first statement, above every early-return guard — so a
// stale spec can never attach to a later, unrelated DealDamage. Same
// discipline as the `ctx.target_chooser = None;` resets in the damage-chain
// splitter.
ctx.pending_damage_multi_target = None;

if let Some(effect) = try_parse_drawn_this_turn_choice(text) {
return parsed_clause(effect);
}
Expand Down Expand Up @@ -14875,6 +14887,11 @@ fn lower_imperative_clause(text: &str, ctx: &mut ParseContext) -> ParsedEffectCl

let (stripped, duration) = strip_trailing_duration(text);
let mut clause = parse_imperative_effect(stripped, ctx);
// CR 601.2c: paired with the reset at the top of this function. The count is
// produced by the same parse that produced the target filter
// (`parse_each_of_target_distribution`), so it is the primary authority for
// the DealDamage fixup below.
let pending_damage_multi_target = ctx.pending_damage_multi_target.take();
if clause.duration.is_none() {
clause.duration = duration;
}
Expand All @@ -14895,8 +14912,14 @@ fn lower_imperative_clause(text: &str, ctx: &mut ParseContext) -> ParsedEffectCl
.or_else(|| extract_bounded_target_multi_target(text))
.or_else(|| extract_optional_target_multi_target(text));
}
// CR 601.2c + CR 115.4: the announced target count for a
// "⟨source⟩ deals N damage to each of ⟨count⟩ ⟨noun⟩" head comes from the
// SAME parse that produced the target filter, so the ctx value is the
// primary authority here. The text-scanning extractor stays as a fallback
// for the shapes that combinator does not reach.
if matches!(clause.effect, Effect::DealDamage { .. }) && clause.multi_target.is_none() {
clause.multi_target = extract_deal_damage_multi_target(text);
clause.multi_target =
pending_damage_multi_target.or_else(|| extract_deal_damage_multi_target(text));
}
// CR 115.1d: Post-parse fixup for SwitchPT prepositional form. The
// imperative parser strips "any number of" / "each of" to keep `parse_target`
Expand Down Expand Up @@ -16445,6 +16468,23 @@ fn try_parse_radiance_color_fanout_damage(
text: &str,
lower: &str,
ctx: &mut ParseContext,
) -> Option<ParsedEffectClause> {
// This recognizer speculatively parses the whole clause and abandons it on
// several branches below. A failed attempt must leave every parser-context
// field untouched — including `pending_damage_multi_target`, which
// `parse_each_of_target_distribution` records before any of those bails are
// reached. Same clone-and-commit shape as
// `try_parse_multi_target_damage_chain`.
let mut tentative_ctx = ctx.clone();
let clause = try_parse_radiance_color_fanout_damage_inner(text, lower, &mut tentative_ctx)?;
*ctx = tentative_ctx;
Some(clause)
}

fn try_parse_radiance_color_fanout_damage_inner(
text: &str,
lower: &str,
ctx: &mut ParseContext,
) -> Option<ParsedEffectClause> {
let (primary_effect, remainder) = try_parse_damage_with_remainder(text, lower, ctx)?;

Expand Down Expand Up @@ -17197,6 +17237,13 @@ fn try_split_damage_compound(text: &str, ctx: &mut ParseContext) -> Option<Parse
let (primary_effect, remainder) = try_parse_damage_with_remainder(text, &lower, ctx)?;
// Preserve the primary target's announcer while parsing the continuation.
let primary_target_chooser = ctx.target_chooser.clone();
// CR 601.2c: same for the primary clause's announced target count. The
// continuation below re-enters `lower_imperative_clause`, which clears
// `pending_damage_multi_target` on entry, so without this save/restore a
// compound "… to each of two target creatures and …" would lose the count
// the primary parse just recorded and fall back to the text-scanning
// extractor — i.e. silently back to a single mandatory target.
let primary_damage_multi_target = ctx.pending_damage_multi_target.take();

if remainder.is_empty() {
return None;
Expand All @@ -17222,6 +17269,12 @@ fn try_split_damage_compound(text: &str, ctx: &mut ParseContext) -> Option<Parse
let mut sub_clause = parse_effect_clause(sub_text, ctx);
let sub_target_chooser = ctx.target_chooser.clone();
ctx.target_chooser = primary_target_chooser;
// CR 601.2c: drop whatever the continuation recorded. The primary head's own
// count was saved before the sub-parse and is attached to the returned clause
// below — this function returns straight out of `lower_imperative_clause`,
// ahead of the `take()` + DealDamage fixup, so the count has to be applied
// here or it is lost.
ctx.pending_damage_multi_target = None;

// Guard: if the sub-text parsed to Unimplemented, it's likely a target phrase
// continuation ("each creature and planeswalker they control") rather than an
Expand All @@ -17248,7 +17301,9 @@ fn try_split_damage_compound(text: &str, ctx: &mut ParseContext) -> Option<Parse
duration: None,
sub_ability: Some(Box::new(sub_ability)),
distribute: None,
multi_target: None,
// CR 601.2c: the primary head's announced target count, captured before
// the continuation's nested clause parse cleared the side channel.
multi_target: primary_damage_multi_target,
condition: None,
optional: false,
unless_pay: None,
Expand Down
Loading
Loading