diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 6928577f50..9de5dc2442 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -4746,7 +4746,8 @@ fn replacement_details(repl: &ReplacementDefinition) -> Vec<(String, String)> { ReplacementMode::Optional { .. } => d.push(("mode".into(), "optional".into())), ReplacementMode::MayCost { .. } => d.push(("mode".into(), "may pay cost".into())), } - // Shield kind, including the prevented amount (ShieldKind::Prevention). + // Shield kind, including the prevented amount (ShieldKind::Prevention / + // the one-shot ShieldKind::PreventionOneShot). if !repl.shield_kind.is_none() { d.push(("shield".into(), format!("{:?}", repl.shield_kind))); } diff --git a/crates/engine/src/game/effects/prevent_damage.rs b/crates/engine/src/game/effects/prevent_damage.rs index a3776f80a6..06675c7d5f 100644 --- a/crates/engine/src/game/effects/prevent_damage.rs +++ b/crates/engine/src/game/effects/prevent_damage.rs @@ -3,7 +3,7 @@ use crate::game::quantity::resolve_quantity; use crate::types::ability::{ CombatDamageScope, DamageTargetFilter, DamageTargetPlayerScope, Effect, EffectError, EffectKind, FilterProp, PreventionAmount, PreventionScope, ReplacementDefinition, - ResolvedAbility, SubAbilityLink, TargetFilter, TargetRef, + ResolvedAbility, ShieldKind, SubAbilityLink, TargetFilter, TargetRef, }; use crate::types::events::GameEvent; use crate::types::game_state::{GameState, PendingContinuation, WaitingFor}; @@ -371,6 +371,29 @@ pub fn resolve( .prevention_shield(amount) .description("Prevent damage".to_string()); + // CR 615.1a + CR 615.3 + CR 614.1a: A one-shot "the next time [target + // creature] would deal damage this turn, prevent that damage" shield (Awe + // Strike) is recognized by its EXACT source-filter shape — the shared + // `is_oneshot_target_source_prevent_shape` predicate (the single authority, + // also consumed by the parser-side bare-rider gate in assembly.rs). Only + // this shape is one-shot: Dromoka's Command's source-scoped shield + // (`Typed(instant|sorcery)` leaf) is a duration-bound continuous + // `Prevention { All }` that must keep re-firing, so it does NOT match here. + // + // NOTE: `target: Any` on the parsed effect is deliberately not consulted on + // the `source_scoped_prevent` path below — the target slot is carried by + // the `damage_source_filter`'s `And`, and `Any` simply means "no recipient + // scope" (CR 115.1: the target slot is hosted by the source-filter `And`). + let oneshot_source_shape = effect_source_filter + .as_ref() + .is_some_and(crate::types::ability::is_oneshot_target_source_prevent_shape); + if oneshot_source_shape { + // CR 615.3: the single opportunity is bounded by the "the next time" + // qualifier — consumed on apply; CR 514.2: expires at cleanup. + shield.consume_on_apply = true; + shield.shield_kind = ShieldKind::PreventionOneShot; + } + // CR 511.2 + CR 615: Apply the parsed prevention window as the shield's // expiry. "this combat" -> `RestrictionExpiry::EndOfCombat`, pruned at the // EndCombat phase (turns.rs) so a Suppressor Skyguard shield from combat 1 @@ -469,6 +492,23 @@ pub fn resolve( // sub is an independent instruction (CR 700.2d — a separate chosen mode of a // modal spell, e.g. Dromoka's Command mode 3), NOT a rider; it is resolved // on its own by the chain walker and must not become the shield rider. + // + // CR 615.5: AWE STRIKE — "You gain life equal to the damage prevented this + // way" is a bare prevented-this-way rider (no when/whenever/if prelude). It + // reaches this resolver as a `ContinuationStep` only for the one-shot + // shape: the assembly gate (assembly.rs) forces `ContinuationStep` for the + // bare rider only when the chain root's prevention carries the + // `And{[ParentTargetSlot, Typed(creature)]}` source filter; for every other + // chain root (e.g. Reverse Damage's `ChosenDamageSource` shape) the bare + // rider stays a `SequentialSibling` and must NOT install here. + // + // The rider is installed via the SAME `runtime_execute` slot as every other + // prevention rider — the resolution-time `ResolvedAbility` payload. The + // applier resolves `EventContextAmount` in the rider against + // `last_effect_count` (stamped with the prevented amount at apply time), so + // no parse-time template reconstruction is needed; the whole sub-ability is + // cloned verbatim, preserving every field the canonical + // `build_resolved_from_def` converter round-trips. if let Some(sub_ability) = &ability.sub_ability { if sub_ability.sub_link == SubAbilityLink::ContinuationStep { shield = shield.runtime_execute(sub_ability.as_ref().clone()); diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 364feea256..b0ded3ff4c 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -2560,6 +2560,60 @@ fn damage_done_applier( } } + // CR 615.3 + CR 615.1a: One-shot prevention shield ("the next time [target + // creature] would deal damage this turn, prevent that damage" — Awe Strike). + // Single opportunity bounded by the "the next time" qualifier (CR 615.3); + // the `Prevention { All }`-style body absorbs any magnitude of the single + // matching damage event. The one-shot consumption itself is handled by the + // generic `consume_on_apply` contract (applier `Prevented`/`Modified` arms) + // rather than here. Per-event path throughout — even inside a combat-damage + // batch the shield matches at most one event (it is consumed on apply), so + // the per-event `DamagePrevented` + `last_effect_count` stamp fires the + // rider once with the exact prevented amount. + // + // CR 120.8: A 0-damage event is not damage at all — it has no event to + // replace. The shield must not "prevent" it (no DamagePrevented, no + // `last_effect_count` stamp, no Prevented), and by CR 609.7b the shield is + // not used up by a prevention that prevents no damage. Fall through to the + // pass-through below so the unmodified event proceeds. The upstream + // `pre_replacement_damage_gate` (CR 120.8) already drops 0-damage events + // before the pipeline on every production path; this guard exists because + // the pipeline itself is a public, testable seam (`replace_event`) and a + // 0-damage `ProposedEvent::Damage` must be a no-op here, never a shield + // burn. (The event is still returned as `Modified(event)` — unchanged — + // which by design does not trigger the dispatcher's `consume_on_apply` + // consumption, since the event took no modification.) + if matches!(shield_kind, Some(ShieldKind::PreventionOneShot)) { + if let ProposedEvent::Damage { + source_id, + target, + amount: dmg, + is_combat, + applied, + } = event + { + if dmg == 0 { + return ApplyResult::Modified(ProposedEvent::Damage { + source_id, + target, + amount: dmg, + is_combat, + applied, + }); + } + events.push(GameEvent::DamagePrevented { + source_id, + target: target.clone(), + amount: dmg, + }); + // CR 615.5: stamp the prevented amount for the rider's + // `EventContextAmount` ("You gain life equal to the damage + // prevented this way"). + state.last_effect_count = Some(dmg as i32); + return ApplyResult::Prevented; + } + } + // No modification and no prevention shield — pass through ApplyResult::Modified(event) } @@ -5524,6 +5578,12 @@ fn is_damage_prevention_replacement( ); } + // CR 615.3: a source-qualified one-shot shield prevents damage rather than + // redirecting it, so "damage can't be prevented" suppresses it as well. + if matches!(repl.shield_kind, ShieldKind::PreventionOneShot) { + return true; + } + // Legacy: description-based prevention from parsed replacement definitions repl.description.as_ref().is_some_and(|d| { let lower = d.to_lowercase(); @@ -8426,6 +8486,16 @@ fn apply_single_replacement( // condition `PostReplacementDamageSourceMatchesFilter` (and/or a // `PostReplacementDamageSource` reflection target) is the per-source // marker; Inkshield/New Way Forward carry neither and keep batching. + // + // CR 615.3 + CR 615.5 (Awe Strike): the one-shot `PreventionOneShot` + // shield stays on the per-event path even inside a combat-damage + // batch — it is single-opportunity (consumed on first apply), so the + // batch contains at most one matching event from the one captured + // source, and the per-event stash + inline drain in + // `replace_combat_damage_batch` fires its template rider exactly + // once. The batch aggregation path would require the post-batch + // rider firing in `combat_damage.rs`, which this shield deliberately + // does not use. let batched_combat_all_shield = state.combat_prevention_tally.is_some() && repl_def.runtime_execute.is_some() && !repl_def @@ -8722,6 +8792,22 @@ fn apply_single_replacement( _ => None, }; let replacement_applied = proposed.applied_set().clone(); + // CR 614.5 + CR 609.7b: a one-shot replacement is consumed when it + // *successfully applies*. The single exception is a `PreventionOneShot` + // damage shield whose applier returns the event UNMODIFIED — the + // CR 120.8 0-damage pass-through, which prevented nothing. A shield that + // prevents no damage is not used up (CR 609.7b), so it must survive for + // the next nonzero damage event. Snapshot the pre-applier event for + // exactly these shields (every other consume_on_apply replacement — + // draw count-modifiers and full-substitution shields — carries its + // application in the definition, not in the returned event, and consumes + // as before). + let pre_applier_event = (consume_on_apply + && matches!( + shield_kind_for_rid(state, rid), + Some(ShieldKind::PreventionOneShot) + )) + .then(|| proposed.clone()); // CR 614.6 + CR 614.12a: Optional `Prevent` replacements (Obstinate Familiar, // Island Sanctuary — "you may skip that draw") suppress the event only on @@ -8818,7 +8904,18 @@ fn apply_single_replacement( _ => {} } } - if consume_on_apply { + // CR 614.5 + CR 609.7b: a `Modified` result that left the + // `PreventionOneShot` event unchanged applied nothing — + // consuming the shield would burn a "the next time" + // opportunity on a 0-damage event that did not happen + // (CR 120.8). `pre_applier_event` is `Some` exactly for those + // shields (see the snapshot above); every other + // `consume_on_apply` replacement consumes unconditionally. + if pre_applier_event + .as_ref() + .is_some_and(|before| new_event != *before) + || (pre_applier_event.is_none() && consume_on_apply) + { mark_replacement_consumed(state, rid); } // CR 614.12a: Stash the mandatory execute ability as a post-replacement @@ -9208,8 +9305,12 @@ fn candidate_materiality( // double-then-prevent do not commute ((3-2)*2 = 2 vs (3*2)-2 = 4). A bare // prevention shield leaves `execute`/`damage_modification` unset, so without // this it fell through to `Disjoint` and the CR 616.1 order choice was - // silently skipped. - if matches!(repl_def.shield_kind, ShieldKind::Prevention { .. }) { + // silently skipped. CR 615.3: the one-shot `PreventionOneShot` shield (Awe + // Strike) writes the same `Damage` field and is equally order-material. + if matches!( + repl_def.shield_kind, + ShieldKind::Prevention { .. } | ShieldKind::PreventionOneShot + ) { return CandidateMateriality::Writes { field: EventField::Damage, commute: CommuteClass::NonCommuting, diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 7f0db0704b..0d7e734e11 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -23,6 +23,7 @@ use crate::parser::oracle_ir::effect_chain::{ }; use crate::parser::oracle_nom::bridge::nom_on_lower; use crate::parser::oracle_nom::error::OracleError; +use crate::parser::oracle_nom::primitives as nom_primitives; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AggregateFunction, CastFromZoneDriver, CastingPermission, ChoiceType, Comparator, ControllerRef, DamageChannel, @@ -1330,6 +1331,31 @@ fn damage_amount_reads_event_context(effect: &Effect) -> bool { reads } +/// CR 615.5 + CR 609.7a + CR 609.7b: true when the chain's most recent +/// `PreventDamage` is the one-shot TARGET-SOURCE shape — a +/// `damage_source_filter` matching the shared +/// `crate::types::ability::is_oneshot_target_source_prevent_shape` predicate +/// (the single authority, also consumed by the resolver discriminator in +/// `prevent_damage::resolve`). The bare "prevented this way" rider folds into +/// the shield only for this root; other prevention roots (Reverse Damage's +/// `ChosenDamageSource`, Comeuppance's color-axis source filter, ...) keep +/// their bare rider as an independent `SequentialSibling`. +fn is_oneshot_target_source_prevent_chain(defs: &[AbilityDefinition]) -> bool { + defs.iter() + .rfind(|d| matches!(&*d.effect, Effect::PreventDamage { .. })) + .is_some_and(|d| { + matches!( + &*d.effect, + Effect::PreventDamage { + damage_source_filter: Some(source_filter), + .. + } if crate::types::ability::is_oneshot_target_source_prevent_shape( + source_filter + ) + ) + }) +} + /// The recipient of a single-recipient scalar instruction — the position a /// "… to them" / "… they lose" player anaphor occupies. Paired with /// [`scalar_amount_mut`], which reads the "that much" position of the same @@ -2227,13 +2253,35 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { // prevention — `any` covers Comeuppance's TWO riders, whose second rider's // immediate predecessor is the first rider, not the PreventDamage) so the // clause is folded into the prevention rather than dropped as a sibling. + // + // CR 615.5: AWE STRIKE — "You gain life equal to the damage prevented + // this way." is a BARE rider (no when/whenever/if prelude; the "this + // way" binds to the preceding prevention sentence). It folds into the + // shield ONLY when the chain root's prevention is the one-shot + // target-source shape (`And { [ParentTargetSlot, Typed(creature)] }` + // damage_source_filter) — that is the Awe Strike class. Reverse Damage's + // chain root (`ChosenDamageSource`) must NOT fold: its bare rider stays a + // `SequentialSibling` (its "equal to the damage prevented this way" + // quantity still resolves via `last_effect_count`). let prevented_this_way_gate = if defs .iter() .any(|d| matches!(&*d.effect, Effect::PreventDamage { .. })) { - crate::parser::oracle_replacement::prevented_this_way_rider_source_gate( - clause_ir.source.fragment().unwrap_or_default(), - ) + let fragment = clause_ir.source.fragment().unwrap_or_default(); + let gate = + crate::parser::oracle_replacement::prevented_this_way_rider_source_gate(fragment); + // Bare-rider arm (no when/whenever/if): recognized only for the + // one-shot target-source prevention chain root. The gate's explicit + // when/whenever/if forms are unchanged and shape-unrestricted. + if gate.is_none() && is_oneshot_target_source_prevent_chain(&defs) { + nom_primitives::scan_at_word_boundaries( + fragment, + tag::<_, _, OracleError<'_>>("prevented this way"), + ) + .map(|_| None) + } else { + gate + } } else { None }; diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 87be07b3a7..2de2b63ec4 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -10212,7 +10212,8 @@ pub(super) fn parse_imperative_family_ast( // above, before the first-word verb dispatch. The detector is the prefix // combinator inside `parse_oneshot_damage_replacement`; on failure it returns // `None` and we fall through. - if let Some(effect) = crate::parser::oracle_replacement::parse_oneshot_damage_replacement(lower) + if let Some(effect) = + crate::parser::oracle_replacement::parse_oneshot_damage_replacement(lower, &*ctx) { return Some(ImperativeFamilyAst::GainKeyword(effect)); } diff --git a/crates/engine/src/parser/oracle_ir/context.rs b/crates/engine/src/parser/oracle_ir/context.rs index 3de649811a..15574d33e2 100644 --- a/crates/engine/src/parser/oracle_ir/context.rs +++ b/crates/engine/src/parser/oracle_ir/context.rs @@ -59,7 +59,12 @@ pub(crate) struct ParseContext { #[allow(dead_code)] // Retained for future nom combinator consumers (D-02). pub quantity_ref: Option, /// Whether we are inside a trigger effect (enables event context refs). - #[allow(dead_code)] // Retained for future nom combinator consumers (D-02). + /// + /// Consumed by `oracle_replacement::parse_oneshot_target_source_prevent` + /// (the Awe Strike one-shot target-source prevention branch): inside a + /// trigger body "that creature" is an event-context anaphor resolved by + /// the trigger machinery, not a target-source capture, so the branch must + /// not claim trigger-body text (Ria Ivor keeps its fall-through shapes). pub in_trigger: bool, /// Whether we are inside a replacement effect. #[allow(dead_code)] // Retained for future nom combinator consumers (D-02). diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 00b32028ad..c297f902da 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -6337,7 +6337,15 @@ fn damage_target_filter_to_prevent_target(filter: Option<&DamageTargetFilter>) - /// The detector IS the parser: the one-shot branch is gated by the /// `tag("the next time ")` prefix combinator succeeding, never a string /// heuristic. Returns `None` (fall-through) when the prefix or grammar fails. -pub(crate) fn parse_oneshot_damage_replacement(norm_lower: &str) -> Option { +/// +/// `ctx.in_trigger` is read only by the `parse_oneshot_target_source_prevent` +/// sub-branch (the "that creature" subject form is a target-source anaphor that +/// must not fire inside a trigger body, where "that creature" resolves to the +/// triggering creature's event context instead). +pub(crate) fn parse_oneshot_damage_replacement( + norm_lower: &str, + ctx: &ParseContext, +) -> Option { // CR 614.9: passive-voice one-shot redirection — "the next N damage that // would be dealt to ~ this turn is dealt to instead" (the en-Kor // cycle). This "would be dealt to" (passive, recipient-first) spine is not @@ -6355,6 +6363,19 @@ pub(crate) fn parse_oneshot_damage_replacement(norm_lower: &str) -> Option is dealt to // instead" (Heroic Sacrifice, Gideon's Sacrifice, Saving Grace). Its required @@ -6490,6 +6511,158 @@ pub(crate) fn parse_oneshot_damage_replacement(norm_lower: &str) -> Option Option { + // CR 603.1: inside a trigger body "that creature" is an event-context + // anaphor resolved by the trigger machinery, not a target-source capture — + // the Awe Strike class of target-source prevention only exists in + // spell/activated-ability bodies. Ria Ivor (trigger body) and Impulsive + // Maneuvers (activated body) both keep their fall-through shapes. + if ctx.in_trigger { + return None; + } + + // CR 614.1a: "the next time " prefix + the subject slice before "would deal". + let (after_prefix, _) = preceded( + tag::<_, _, OracleError<'_>>("the next time "), + peek(take_until::<_, _, OracleError<'_>>("would deal")), + ) + .parse(norm_lower) + .ok()?; + // CR 511.2: the one-shot window is "this turn" (CR 514.2 cleanup) or "this + // combat" (end-of-combat expiry). + if !nom_primitives::scan_contains(after_prefix, "would deal") + || !(nom_primitives::scan_contains(after_prefix, "this turn") + || nom_primitives::scan_contains(after_prefix, "this combat")) + { + return None; + } + let body = after_prefix.trim(); + + // Subject split: the head noun phrase before "would deal". + let (_, (subject, _)) = nom_primitives::split_once_on(body, "would deal").ok()?; + let subject = subject.trim(); + + // CR 115.1: declared-target subject — "target creature" / "another target + // creature" / "other target creature" — or the non-trigger anaphor "that + // creature". The shared `parse_declared_target_prefix` combinator is the + // single authority for the target-prefix family. + let (target_filter, after_subject) = if let Ok((after_prefix, _)) = + crate::parser::oracle_nom::target::parse_declared_target_prefix(subject) + { + let (filter, rest) = parse_target(after_prefix); + if rest.trim().is_empty() { + (filter, None) + } else { + (TargetFilter::Any, Some(rest)) + } + } else if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>("that creature").parse(subject) { + // CR 608.2c: "that creature" in a NON-trigger context is an anaphor + // to the chosen target creature (Dazzling Reflection). The typed + // `Typed(creature)` leaf is emitted DIRECTLY rather than routed + // through `parse_target`: a ctx-free `parse_target` call resolves the + // demonstrative via `resolve_pronoun_target`'s default branch to + // `ParentTarget`, which would drop the CR 609.7b recheck leaf and + // break the exact-shape contract of + // `is_oneshot_target_source_prevent_shape` (`And{[ParentTargetSlot{0}, + // Typed(creature)]}`). Theoretical gap (noted): "that creature" with a + // QUALIFIER ("that creature spell", "that creature you control") is + // not in the current corpus; if it ever appears, this branch must + // extend to parse the qualified noun phrase and the shape predicate + // must be revisited accordingly. + if rest.trim().is_empty() { + (TargetFilter::Typed(TypedFilter::creature()), None) + } else { + (TargetFilter::Any, Some(rest)) + } + } else { + (TargetFilter::Any, None) + }; + + // Out-of-bounds protection: any unconsumed subject tail (e.g. "target + // creature card", "target creature or player") or a subject that is not a + // declared-target/that-creature form falls through to the generic branch. + if after_subject.is_some() || target_filter == TargetFilter::Any { + return None; + } + + // CR 615.1a: the prevention body must be a "prevent that damage" / + // "prevent the damage" result clause (the whole one-shot sentence, from + // "would deal" onward). + let (would_clause, result_clause) = split_would_deal_clause(body); + if !nom_primitives::scan_contains(result_clause, "prevent that damage") + && !nom_primitives::scan_contains(result_clause, "prevent the damage") + { + return None; + } + + // CR 511.2: "this turn" → UntilEndOfTurn (expires at cleanup, CR 514.2); + // "this combat" → UntilEndOfCombat (combat-scoped one-shots). The shared + // `parse_duration` authority recognizes both via `parse_current_phase_duration`. + // + // The duration is MANDATORY for this branch: the sentence already proved a + // "this turn" / "this combat" phrase at the gate above, so a parse failure + // here means the phrase shape is one this branch must not claim — return + // `None` (fail-closed, fall through to the generic one-shot branch) rather + // than emitting a `prevention_duration: None` shield that would resolve + // without an EndOfCombat expiry and let a "this combat" effect bleed past + // the end of combat (CR 511.2). + let duration: Option = match nom_primitives::scan_preceded(body, parse_duration) { + Some((_, d, _)) => Some(d), + None => return None, + }; + + // CR 506.2: "combat damage" narrows the scope to combat damage only. + let scope = if nom_primitives::scan_contains(would_clause, "combat damage") { + crate::types::ability::PreventionScope::CombatDamage + } else { + crate::types::ability::PreventionScope::AllDamage + }; + + Some(Effect::PreventDamage { + amount: PreventionAmount::All, + amount_dynamic: None, + // CR 115.1: the target slot is hosted by `damage_source_filter`'s `And` + // (`ParentTargetSlot { 0 }` captures the chosen creature, CR 609.7a); + // `Any` here means "no additional recipient scope" and is not consulted + // on the source-scoped prevent path. + target: TargetFilter::Any, + scope, + damage_source_filter: Some(TargetFilter::And { + filters: vec![ + // CR 609.7a: the chosen target creature is captured as the + // damage SOURCE via the first target slot. + TargetFilter::ParentTargetSlot { index: 0 }, + // CR 609.7b: the typed leaf rechecks the source's properties at + // damage time ("a creature"). + target_filter, + ], + }), + prevention_duration: duration, + }) +} + /// CR 614.11 + CR 614.6 + CR 514.2: Parse a one-shot delayed DRAW replacement — /// "the next time you would draw a card this turn, [effect] instead" (Words of /// Worship: "you gain 5 life"; Words of Wilding: "create a 2/2 green Bear @@ -19717,7 +19890,7 @@ mod tests { ), ] { assert!( - parse_oneshot_damage_replacement(clause).is_none(), + parse_oneshot_damage_replacement(clause, &ParseContext::default()).is_none(), "{label} must not be claimed by the continuous redirection class" ); } @@ -19727,6 +19900,7 @@ mod tests { // not by the whole family being unreachable. let effect = parse_oneshot_damage_replacement( "all damage that would be dealt to you is dealt to the chosen creature instead", + &ParseContext::default(), ) .expect("the in-class control clause must parse"); assert!(matches!( @@ -19746,6 +19920,7 @@ mod tests { // trigger head is a separate, still-unsupported gap — see the report). let effect = parse_oneshot_damage_replacement( "all damage that would be dealt this turn to you and permanents you control is dealt to enchanted creature instead", + &ParseContext::default(), ) .expect("Saving Grace's redirection clause must parse"); assert!(matches!( @@ -19762,7 +19937,11 @@ mod tests { // Pariah's printed static, which belongs to `parse_replacement_line`'s // durable spine and must never be claimed as an effect-created shield. assert!( - parse_oneshot_damage_replacement("enchanted creature gets +2/+2").is_none(), + parse_oneshot_damage_replacement( + "enchanted creature gets +2/+2", + &ParseContext::default(), + ) + .is_none(), "an unrelated attachment-host line must not be claimed" ); } @@ -22597,7 +22776,7 @@ mod snapshot_tests { // Desperate Gambit win-branch. let effect = parse_oneshot_damage_replacement( "the next time that source would deal damage this turn, it deals double that damage instead", - ) + &ParseContext::default()) .expect("must parse amount one-shot"); match effect { Effect::CreateDamageReplacement { @@ -22616,7 +22795,7 @@ mod snapshot_tests { // Soltari Guerrillas. let effect = parse_oneshot_damage_replacement( "the next time ~ would deal combat damage to an opponent this turn, it deals that damage to target creature instead", - ) + &ParseContext::default()) .expect("must parse redirection one-shot"); match effect { Effect::CreateDamageReplacement { @@ -22646,7 +22825,7 @@ mod snapshot_tests { // you control. let effect = parse_oneshot_damage_replacement( "the next 1 damage that would be dealt to ~ this turn is dealt to target creature you control instead", - ) + &ParseContext::default()) .expect("must parse the en-Kor one-shot redirection"); match effect { Effect::CreateDamageReplacement { @@ -22673,7 +22852,7 @@ mod snapshot_tests { // Beacon of Destiny — passive "that damage is dealt to ~ instead". let effect = parse_oneshot_damage_replacement( "the next time a source of your choice would deal damage to you this turn, that damage is dealt to ~ instead", - ) + &ParseContext::default()) .expect("must parse passive redirection one-shot"); match effect { Effect::CreateDamageReplacement { @@ -22692,7 +22871,7 @@ mod snapshot_tests { // Jade Monolith. let effect = parse_oneshot_damage_replacement( "the next time a source of your choice would deal damage to target creature this turn, that source deals that damage to you instead", - ) + &ParseContext::default()) .expect("must parse redirect-to-you one-shot"); match effect { Effect::CreateDamageReplacement { @@ -22717,7 +22896,7 @@ mod snapshot_tests { // Goblin Psychopath. let effect = parse_oneshot_damage_replacement( "the next time it would deal combat damage this turn, it deals that damage to you instead", - ) + &ParseContext::default()) .expect("must parse Goblin Psychopath one-shot"); match effect { Effect::CreateDamageReplacement { @@ -22738,6 +22917,7 @@ mod snapshot_tests { // keeps bare "it" as SelfRef; chains with ChooseDamageSource rewrite at lower time. let effect = parse_oneshot_damage_replacement( "the next time it would deal damage this turn, prevent that damage", + &ParseContext::default(), ) .expect("must parse prevention sibling"); match effect { @@ -22764,7 +22944,7 @@ mod snapshot_tests { // rechecks color at damage time — NOT dropped to an unconstrained shield. let effect = parse_oneshot_damage_replacement( "the next time a red source of your choice would deal damage to you this turn, prevent that damage", - ) + &ParseContext::default()) .expect("Circle of Protection: Red must parse"); match effect { Effect::PreventDamage { @@ -22802,7 +22982,7 @@ mod snapshot_tests { // (distinct from the color-qualified Circles/Runes). let effect = parse_oneshot_damage_replacement( "the next time a land source of your choice would deal damage to you this turn, prevent that damage", - ) + &ParseContext::default()) .expect("Rune of Protection: Lands must parse"); match effect { Effect::PreventDamage { @@ -22832,13 +23012,15 @@ mod snapshot_tests { // A draw-form "the next time" must not be hijacked by the DAMAGE parser // (it has no "would deal" spine) — the draw parser claims it instead. assert!(parse_oneshot_damage_replacement( - "the next time you would draw a card this turn, draw two cards instead" + "the next time you would draw a card this turn, draw two cards instead", + &ParseContext::default() ) .is_none()); // A genuinely-unrelated "the next time" (not draw, not damage) parses to // neither one-shot replacement. assert!(parse_oneshot_damage_replacement( - "the next time you would gain life this turn, you gain twice that much instead" + "the next time you would gain life this turn, you gain twice that much instead", + &ParseContext::default() ) .is_none()); assert!(parse_oneshot_draw_replacement( @@ -23111,7 +23293,7 @@ mod snapshot_tests { // itself (`~` → SourceObject), which needs no redirect slot. let effect = parse_oneshot_damage_replacement( "the next 1 damage that would be dealt to target white creature this turn is dealt to ~ instead", - ) + &ParseContext::default()) .expect("must parse redirect-target-to-source one-shot"); match effect { Effect::CreateDamageReplacement { @@ -23138,7 +23320,7 @@ mod snapshot_tests { // redirect destination is the controller (`you` → Controller). let effect = parse_oneshot_damage_replacement( "the next 1 damage that would be dealt to target legendary creature you control this turn is dealt to you instead", - ) + &ParseContext::default()) .expect("must parse redirect-target-to-controller one-shot"); match effect { Effect::CreateDamageReplacement { @@ -23163,7 +23345,7 @@ mod snapshot_tests { // SelfRef), NOT the new target-recipient arm. let effect = parse_oneshot_damage_replacement( "the next 1 damage that would be dealt to ~ this turn is dealt to target creature you control instead", - ) + &ParseContext::default()) .expect("en-Kor self redirect must still parse"); assert!( matches!( @@ -23186,7 +23368,7 @@ mod snapshot_tests { // destination are chosen object targets (two slots). let effect = parse_oneshot_damage_replacement( "the next 3 damage that would be dealt to target creature you control this turn is dealt to another target creature instead", - ) + &ParseContext::default()) .expect("must parse redirect-target-to-chosen-target one-shot"); match effect { Effect::CreateDamageReplacement { @@ -23216,14 +23398,14 @@ mod snapshot_tests { assert!( parse_oneshot_damage_replacement( "the next 1 damage that would be dealt to ~ this turn is dealt to any target instead", - ) + &ParseContext::default()) .is_none(), "en-Kor 'any target' redirect must fail closed (object-only resolver)" ); assert!( parse_oneshot_damage_replacement( "the next 1 damage that would be dealt to target creature you control this turn is dealt to any target instead", - ) + &ParseContext::default()) .is_none(), "chosen-recipient 'any target' redirect must fail closed (object-only resolver)" ); @@ -23622,6 +23804,404 @@ mod snapshot_tests { "the 'to you' player shield must not be flipped to SelfRef" ); } + + // ── Awe Strike class: one-shot target-source prevention ──────────────── + + /// Awe Strike front clause, verbatim (non-trigger context): must parse to + /// the one-shot target-source `PreventDamage` — recipient `Any`, source + /// filter `And { [ParentTargetSlot { 0 }, Typed(creature)] }`, all-damage + /// scope, until-end-of-turn duration. Reverting the new branch drops the + /// target creature (source filter `None`, target `Any`) — the primary + /// discriminating assertion is the `And` source-filter shape. + #[test] + fn awe_strike_front_clause_parses_target_source_prevent() { + let effect = parse_oneshot_damage_replacement( + "the next time target creature would deal damage this turn, prevent that damage", + &ParseContext::default(), + ) + .expect("Awe Strike front clause must parse to the target-source prevention"); + match effect { + Effect::PreventDamage { + amount, + target, + scope, + damage_source_filter, + prevention_duration, + .. + } => { + assert_eq!(amount, PreventionAmount::All); + assert_eq!(target, TargetFilter::Any); + assert_eq!(scope, crate::types::ability::PreventionScope::AllDamage); + assert_eq!( + damage_source_filter, + Some(TargetFilter::And { + filters: vec![ + TargetFilter::ParentTargetSlot { index: 0 }, + TargetFilter::Typed(TypedFilter::creature()), + ], + }), + "the chosen target creature must be captured as the damage source \ + (CR 609.7a) with the typed CR 609.7b recheck leaf" + ); + assert_eq!( + prevention_duration, + Some(Duration::UntilEndOfTurn), + "CR 514.2: 'this turn' ends at cleanup" + ); + } + other => panic!("expected PreventDamage, got {other:?}"), + } + } + + /// Blocker-2 regression (review #7334): this branch must NEVER emit a + /// `PreventionOneShot`-class shield with `prevention_duration: None` — the + /// resolver keys the EndOfCombat expiry (CR 511.2) off the parsed + /// duration, and a "this combat" shield without one would bleed past the + /// end of combat. The duration parse is therefore MANDATORY for the + /// branch: `scan_preceded(body, parse_duration)` failing means the branch + /// returns `None` (fail-closed, falls through to the generic one-shot + /// branch) instead of lowering with a `None` duration. + /// + /// Reachability note (honest): with today's `parse_duration` coverage the + /// fail-closed arm is defensive rather than reachable — the branch's own + /// gate (`scan_contains` on "this turn"/"this combat") and + /// `parse_current_phase_duration` recognize exactly the same phrases, so a + /// gate-passing sentence always resolves a duration. The `?` is the + /// tripwire for the day `parse_duration` coverage drifts (e.g. a "this + /// combat" arm removed): the branch then fails closed to `None` instead of + /// silently shipping a `None`-duration shield. These assertions pin that + /// contract at its observable surface: every canonical one-shot + /// target-source form carries `Some(duration)`, and the no-phrase form is + /// `None` (the existing gate, CR 511.2). + #[test] + fn target_source_prevent_duration_failure_falls_through_to_none() { + // "this turn" form → Some(UntilEndOfTurn), never None. + let turn = parse_oneshot_damage_replacement( + "the next time target creature would deal damage this turn, prevent that damage", + &ParseContext::default(), + ) + .expect("'this turn' form must parse"); + let Effect::PreventDamage { + prevention_duration, + .. + } = &turn + else { + panic!("expected PreventDamage, got {turn:?}"); + }; + assert_eq!( + *prevention_duration, + Some(Duration::UntilEndOfTurn), + "CR 514.2: 'this turn' ends at cleanup — a None duration would leave the \ + shield without an expiry" + ); + + // "this combat" form → Some(UntilEndOfCombat), never None. + let combat = parse_oneshot_damage_replacement( + "the next time target creature would deal combat damage to one or more players this combat, prevent that damage", + &ParseContext::default(), + ) + .expect("'this combat' form must parse"); + let Effect::PreventDamage { + prevention_duration, + .. + } = &combat + else { + panic!("expected PreventDamage, got {combat:?}"); + }; + assert_eq!( + *prevention_duration, + Some(Duration::UntilEndOfCombat), + "CR 511.2: 'this combat' expires at end of combat — a None duration would let \ + the shield bleed into a later combat" + ); + + // "that creature" subject with "this turn" → Some as well. + let that_creature = parse_oneshot_damage_replacement( + "the next time that creature would deal damage this turn, prevent that damage", + &ParseContext::default(), + ) + .expect("'that creature' form must parse"); + let Effect::PreventDamage { + prevention_duration, + .. + } = &that_creature + else { + panic!("expected PreventDamage, got {that_creature:?}"); + }; + assert_eq!( + *prevention_duration, + Some(Duration::UntilEndOfTurn), + "the anaphor form must carry the duration too, never None" + ); + + // Negative: no duration phrase at all — the gate (CR 511.2 window + // requirement) rejects the sentence before the duration step. + assert!( + parse_oneshot_damage_replacement( + "the next time target creature would deal damage, prevent that damage", + &ParseContext::default(), + ) + .is_none(), + "a target-source prevention without a this turn/this combat phrase must be None" + ); + } + + /// "that creature" subject, non-trigger context: the anaphor lowers to the + /// typed `Typed(creature)` leaf in the same `And` shape. + #[test] + fn that_creature_subject_parses_target_source_prevent() { + let effect = parse_oneshot_damage_replacement( + "the next time that creature would deal damage this turn, prevent that damage", + &ParseContext::default(), + ) + .expect("that-creature subject must parse"); + let Effect::PreventDamage { + damage_source_filter, + .. + } = effect + else { + panic!("expected PreventDamage, got {effect:?}"); + }; + assert_eq!( + damage_source_filter, + Some(TargetFilter::And { + filters: vec![ + TargetFilter::ParentTargetSlot { index: 0 }, + TargetFilter::Typed(TypedFilter::creature()), + ], + }) + ); + } + + /// Combat-scoped variant ("this combat" + "combat damage") → CombatDamage + /// scope and UntilEndOfCombat duration. + #[test] + fn combat_scoped_target_source_prevent_sets_combat_scope_and_combat_duration() { + let effect = parse_oneshot_damage_replacement( + "the next time target creature would deal combat damage to one or more players this combat, prevent that damage", + &ParseContext::default(), + ) + .expect("combat-scoped target-source prevention must parse"); + match effect { + Effect::PreventDamage { + scope, + damage_source_filter, + prevention_duration, + .. + } => { + assert_eq!(scope, crate::types::ability::PreventionScope::CombatDamage); + assert_eq!( + prevention_duration, + Some(Duration::UntilEndOfCombat), + "CR 511.2: 'this combat' expires at end of combat" + ); + assert_eq!( + damage_source_filter, + Some(TargetFilter::And { + filters: vec![ + TargetFilter::ParentTargetSlot { index: 0 }, + TargetFilter::Typed(TypedFilter::creature()), + ], + }) + ); + } + other => panic!("expected PreventDamage, got {other:?}"), + } + } + + /// Ria Ivor, Bane of Bladehold — the one-shot prevention lives in a + /// TRIGGER body ("At the beginning of combat on your turn, the next time + /// target creature would deal combat damage ... prevent that damage. If + /// damage is prevented this way, create ..."). Inside a trigger, the new + /// target-source branch must NOT claim it (the `ctx.in_trigger` gate): + /// the prevention sentence stays an honest `Unimplemented` gap and the + /// rider stays a `SequentialSibling`. Reverting the gate mis-parses the + /// trigger body's "target creature" as a spell-side declared target. + #[test] + fn ria_ivor_trigger_body_keeps_fall_through_shapes() { + use crate::types::ability::SubAbilityLink; + use crate::types::triggers::TriggerMode; + + let parsed = parse_oracle_text( + "At the beginning of combat on your turn, the next time target creature would deal combat damage to one or more players this combat, prevent that damage. If damage is prevented this way, create that many 1/1 colorless Phyrexian Mite artifact creature tokens with toxic 1 and \"This token can't block.\"", + "Ria Ivor, Bane of Bladehold", + &[], + &["Creature".to_string()], + &["Phyrexian".to_string(), "Human".to_string(), "Soldier".to_string()], + ); + let trigger = parsed + .triggers + .iter() + .find(|t| t.mode == TriggerMode::Phase) + .expect("beginning-of-combat trigger must exist"); + let body = trigger.execute.as_deref().expect("trigger body"); + // Reach guard: the prevention sentence IS parsed by the chain (as an + // honest Unimplemented gap, not dropped). + assert!( + matches!(&*body.effect, Effect::Unimplemented { .. }), + "the trigger-body prevention sentence must stay an honest Unimplemented gap, got {:?}", + body.effect + ); + // The rider is an independent sibling, not a folded shield rider. + assert_eq!( + body.sub_ability.as_ref().map(|s| s.sub_link), + Some(SubAbilityLink::SequentialSibling), + "the rider must stay a SequentialSibling in a trigger body" + ); + assert!(matches!( + body.sub_ability.as_ref().map(|s| s.effect.as_ref()), + Some(Effect::Token { .. }) + )); + } + + /// Circle of Protection: Red — verbatim. The "a red source of your choice" + /// subject must NOT be claimed by the target-source branch: it keeps the + /// `ChosenDamageSource { filter: Some(red) }` shape with + /// `target: Controller` ("to you"). The `target: Controller` assertion is + /// the reach guard proving the generic prevention branch still runs. + #[test] + fn circle_of_protection_red_stays_chosen_damage_source() { + let effect = parse_oneshot_damage_replacement( + "the next time a red source of your choice would deal damage to you this turn, prevent that damage", + &ParseContext::default(), + ) + .expect("CoP: Red must parse"); + match effect { + Effect::PreventDamage { + target, + damage_source_filter, + .. + } => { + assert_eq!(target, TargetFilter::Controller); + let Some(TargetFilter::ChosenDamageSource { + filter: Some(inner), + }) = damage_source_filter + else { + panic!("expected qualified ChosenDamageSource, got {damage_source_filter:?}"); + }; + match *inner { + TargetFilter::Typed(tf) => assert!( + tf.properties.iter().any(|p| matches!( + p, + FilterProp::HasColor { + color: ManaColor::Red + } + )), + "inner qualifier must constrain to red sources, got {tf:?}" + ), + other => panic!("expected Typed color qualifier, got {other:?}"), + } + } + other => panic!("expected PreventDamage, got {other:?}"), + } + } + + /// Desperate Gambit lose-branch ("it would deal damage this turn, prevent + /// that damage") must keep its bare-`SelfRef` source filter — the "it" + /// subject is not a declared target. + #[test] + fn desperate_gambit_lose_branch_keeps_self_ref_source() { + let effect = parse_oneshot_damage_replacement( + "the next time it would deal damage this turn, prevent that damage", + &ParseContext::default(), + ) + .expect("Desperate Gambit lose-branch must parse"); + let Effect::PreventDamage { + damage_source_filter, + .. + } = effect + else { + panic!("expected PreventDamage, got {effect:?}"); + }; + assert_eq!( + damage_source_filter, + Some(TargetFilter::SelfRef), + "the isolated 'it' subject keeps SelfRef (chain threading rewrites at lower time)" + ); + } + + /// Reverse Damage vs Awe Strike, full verbatim texts: the bare + /// "damage prevented this way" rider folds into the shield ONLY for the + /// one-shot target-source prevention (Awe Strike → ContinuationStep); + /// Reverse Damage's `ChosenDamageSource` root keeps the rider as a + /// SequentialSibling. + #[test] + fn bare_prevented_this_way_rider_folds_only_for_target_source_prevent() { + use crate::types::ability::SubAbilityLink; + + let awe_strike = parse_oracle_text( + "The next time target creature would deal damage this turn, prevent that damage. You gain life equal to the damage prevented this way.", + "Awe Strike", + &[], + &["Instant".to_string()], + &[], + ); + let awe_ability = &awe_strike.abilities[0]; + assert!( + matches!(&*awe_ability.effect, Effect::PreventDamage { .. }), + "Awe Strike must parse to PreventDamage" + ); + assert_eq!( + awe_ability.sub_ability.as_ref().map(|s| s.sub_link), + Some(SubAbilityLink::ContinuationStep), + "Awe Strike's bare rider must fold into the shield as a ContinuationStep" + ); + assert!(matches!( + awe_ability.sub_ability.as_ref().map(|s| s.effect.as_ref()), + Some(Effect::GainLife { .. }) + )); + + let reverse_damage = parse_oracle_text( + "The next time a source of your choice would deal damage to you this turn, prevent that damage. You gain life equal to the damage prevented this way.", + "Reverse Damage", + &[], + &["Instant".to_string()], + &[], + ); + let reverse_ability = &reverse_damage.abilities[0]; + assert!( + matches!(&*reverse_ability.effect, Effect::PreventDamage { .. }), + "Reverse Damage must parse to PreventDamage (reach guard for the sibling assertion)" + ); + assert_eq!( + reverse_ability.sub_ability.as_ref().map(|s| s.sub_link), + Some(SubAbilityLink::SequentialSibling), + "Reverse Damage's ChosenDamageSource root must keep the bare rider a SequentialSibling" + ); + } + + /// P7: Awe Strike's full verbatim text must NOT route through + /// `parse_static_line` (it is an effect-creating one-shot, not a static + /// replacement). + #[test] + fn awe_strike_full_text_is_not_a_static_line() { + assert!(crate::parser::oracle_static::parse_static_line( + "The next time target creature would deal damage this turn, prevent that damage. You gain life equal to the damage prevented this way.", + ) + .is_none()); + } + + /// P8: `ShieldKind::PreventionOneShot` serializes and round-trips, and old + /// JSON without the variant (pre-change exports) still deserializes. + #[test] + fn prevention_oneshot_shield_kind_serializes_and_roundtrips() { + use crate::types::ability::ShieldKind; + + let json = serde_json::to_string(&ShieldKind::PreventionOneShot) + .expect("one-shot shield kind must serialize"); + assert_eq!(json, "\"PreventionOneShot\""); + let back: ShieldKind = + serde_json::from_str(&json).expect("one-shot shield kind must deserialize"); + assert_eq!(back, ShieldKind::PreventionOneShot); + + // Old JSON (pre-variant exports): the omitted `shield_kind` field + // defaults to `ShieldKind::None` via `#[serde(default)]`. + let old = r#"{"event":"DamageDone","consume_on_apply":true}"#; + let repl: ReplacementDefinition = + serde_json::from_str(old).expect("old JSON without shield_kind must load"); + assert_eq!(repl.shield_kind, ShieldKind::None); + assert_eq!(repl.event, ReplacementEvent::DamageDone); + } } #[cfg(test)] diff --git a/crates/engine/src/parser/swallow_check.rs b/crates/engine/src/parser/swallow_check.rs index f4af6f8441..b7fc634320 100644 --- a/crates/engine/src/parser/swallow_check.rs +++ b/crates/engine/src/parser/swallow_check.rs @@ -5278,6 +5278,12 @@ mod tests { "Bronze Horse replacement must parse without Unimplemented" ); let as_long_as = "as long as"; + // CR 611.3: Bronze Horse's "as long as" prevention is a CONTINUOUS + // shield — the exact `Prevention { .. }` assertion (no + // `PreventionOneShot`) pins the one-shot classification from leaking + // onto a duration-bound "as long as" shield: if an implementation + // misclassified this card as `PreventionOneShot` ("the next time" + // single opportunity, CR 615.3), the assertion fails. assert!( bronze.replacements.iter().any(|r| { r.event == ReplacementEvent::DamageDone @@ -5290,6 +5296,42 @@ mod tests { "expected gated damage-prevention replacement, got {:#?}", bronze.replacements ); + // CR 615.3: the Awe Strike class — "the next time [target creature] + // would deal damage this turn, prevent that damage" — is the one-shot + // counterpart of the same shield axis. The spell-side sentence lowers + // to `Effect::PreventDamage` carrying the exact one-shot + // `And{[ParentTargetSlot{0}, Typed(creature)]}` source filter (the + // `is_oneshot_target_source_prevent_shape` discriminator — the single + // authority that classifies the shield as `PreventionOneShot` at + // resolution). The exact-shape assertions for both classes in this + // test prove the parser discriminates continuous ("as long as") from + // one-shot ("the next time") prevention and cannot swap one for the + // other without failing here. + let one_shot = parse_named( + "The next time target creature would deal damage this turn, prevent that damage.", + "Awe Strike", + &["Instant"], + ); + assert!( + one_shot.abilities.iter().any(|a| matches!( + &*a.effect, + Effect::PreventDamage { + damage_source_filter: Some(filter), + .. + } if crate::types::ability::is_oneshot_target_source_prevent_shape(filter) + )), + "the 'the next time' target-source prevention must lower to the one-shot \ + source-filter shape, got {:#?}", + one_shot.abilities + ); + assert!( + !one_shot + .replacements + .iter() + .any(|r| r.shield_kind.is_shield()), + "the one-shot spell sentence must NOT lower to a replacement definition, got {:#?}", + one_shot.replacements + ); // KNOWN GAP, pinned deliberately. Bronze Horse DOES report a swallowed // `Condition_AsLongAs` — and did so in the shipped card data long before this // change (verified against the pre-cutover full-pool export). The assertions diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index dbd723bf62..189ecc215c 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -1398,6 +1398,16 @@ pub enum ShieldKind { #[serde(default)] lifetime: RedirectionLifetime, }, + /// CR 614.1a + CR 615.1a + CR 615.3 + CR 514.2: one-shot prevention shield — + /// "the next time [source] would deal damage this turn, prevent that damage" + /// (Awe Strike). The single opportunity is bounded by the "the next time" + /// qualifier (CR 615.3: prevention effects "last until they're used up or + /// their duration has expired"); the shield is consumed via + /// `consume_on_apply` when the prevention applies, and expires at cleanup + /// (CR 514.2). Distinct from the duration-bound continuous + /// `Prevention { All }` (Fog), which stays active for every matching damage + /// event in its lifetime. + PreventionOneShot, } /// CR 614.5 vs CR 611.2a: how many damage events one `ShieldKind::Redirection` @@ -1444,6 +1454,37 @@ impl ShieldKind { } } +/// CR 615.1a + CR 609.7a + CR 609.7b: EXACT source-filter shape of the +/// one-shot target-source prevention ("the next time target creature would +/// deal damage this turn, prevent that damage" — Awe Strike): an `And` of +/// exactly two legs — a `ParentTargetSlot { 0 }` capture (the chosen target +/// creature, CR 609.7a) and a `Typed` leaf whose type list is exactly +/// `[Creature]` with no zone/color properties (the CR 609.7b recheck). +/// +/// Single authority shared by the parser-side bare-rider gate +/// (`oracle_effect::assembly::is_oneshot_target_source_prevent_chain`) and the +/// resolver-side one-shot discriminator (`prevent_damage::resolve`), so the +/// two cannot drift. Deliberately strict: a `Typed` leaf carrying extra +/// constraints (e.g. "target creature spell" — `InZone(Stack)` + Creature, or +/// a controller clause) does NOT match, so a hypothetical future source-scoped +/// prevent with a qualified creature leaf keeps its continuous +/// `Prevention { All }` semantics instead of silently becoming one-shot. +pub fn is_oneshot_target_source_prevent_shape(source_filter: &TargetFilter) -> bool { + match source_filter { + TargetFilter::And { filters } if filters.len() == 2 => { + matches!(&filters[0], TargetFilter::ParentTargetSlot { index: 0 }) + && matches!( + &filters[1], + TargetFilter::Typed(tf) + if tf.type_filters.as_slice() == [TypeFilter::Creature] + && tf.properties.is_empty() + && tf.controller.is_none() + ) + } + _ => false, + } +} + /// CR 601.2 vs CR 305.1: Distinguishes "cast" (spells only) from "play" (spells + lands). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum CardPlayMode { @@ -25174,6 +25215,15 @@ impl ReplacementDefinition { self } + /// CR 615.1a + CR 615.3 + CR 514.2: Mark this replacement as a one-shot + /// prevention shield ("the next time [source] would deal damage this turn, + /// prevent that damage" — Awe Strike). Single opportunity per CR 615.3; + /// consumed on use, expires at cleanup per CR 514.2. + pub fn prevention_oneshot_shield(mut self) -> Self { + self.shield_kind = ShieldKind::PreventionOneShot; + self + } + /// CR 614.5 + CR 614.1a: Mark this replacement as a one-shot damage-amount /// shield (Desperate Gambit). Pair with `.damage_modification(...)` to set /// the amount formula; the shield is consumed after its single use and diff --git a/crates/engine/tests/integration/awe_strike_prevention.rs b/crates/engine/tests/integration/awe_strike_prevention.rs new file mode 100644 index 0000000000..270b846d26 --- /dev/null +++ b/crates/engine/tests/integration/awe_strike_prevention.rs @@ -0,0 +1,585 @@ +//! Awe Strike — one-shot target-source prevention, driven through the real +//! cast pipeline (GameScenario + GameRunner::cast(...).resolve()). +//! +//! Verbatim Oracle text under test (Scryfall): +//! "The next time target creature would deal damage this turn, prevent that +//! damage. You gain life equal to the damage prevented this way." +//! +//! Defects this guards against (backlog class 11): +//! 1. The target creature was dropped — the shield prevented EVERY source's +//! damage instead of only the chosen creature's. +//! 2. The shield never consumed — "the next time" is a single opportunity +//! (CR 615.3), so later damage events must go through. +//! 3. The "gain life equal to the damage prevented this way" rider was +//! dropped as an independent sibling instead of firing off the shield. +//! +//! CR 614.1a + CR 615.1a + CR 615.3 + CR 609.7a + CR 609.7b + CR 514.2 + +//! CR 115.1. + +use engine::game::effects::deal_damage; +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::ability::{ + Effect, QuantityExpr, ResolvedAbility, ShieldKind, TargetFilter, TargetRef, +}; +use engine::types::events::GameEvent; +use engine::types::phase::Phase; + +const AWE_STRIKE: &str = "The next time target creature would deal damage this turn, prevent that damage. You gain life equal to the damage prevented this way."; + +fn damage_ability( + source_id: engine::types::identifiers::ObjectId, + target: TargetRef, + amount: i32, +) -> ResolvedAbility { + ResolvedAbility::new( + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: amount }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + vec![target], + source_id, + P1, + ) +} + +/// CR 615.1a + CR 615.3: The first damage event from the chosen creature is +/// prevented and the rider gains life equal to the prevented amount exactly +/// once; a second event goes through (single opportunity, CR 615.3). +#[test] +fn awe_strike_prevents_first_source_damage_once_then_lets_second_through() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let b1 = scenario.add_creature(P1, "B1", 3, 3).id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Awe Strike", true, AWE_STRIKE) + .id(); + + let mut runner = scenario.build(); + let p0_before = runner.life(P0); + let outcome = runner.cast(spell).target_objects(&[b1]).resolve(); + assert_eq!( + outcome.life_delta(P0), + 0, + "casting Awe Strike itself must not change P0's life" + ); + + // First event: 3 damage from b1 to P0 → prevented, +3 life (CR 615.5). + let mut events = Vec::new(); + deal_damage::resolve( + runner.state_mut(), + &damage_ability(b1, TargetRef::Player(P0), 3), + &mut events, + ) + .expect("b1's first damage resolves"); + assert_eq!( + runner.life(P0), + p0_before + 3, + "first b1 damage prevented AND P0 gains life equal to the prevented damage" + ); + assert!( + events + .iter() + .any(|e| matches!(e, GameEvent::DamagePrevented { amount: 3, .. })), + "the first damage event must emit DamagePrevented(3)" + ); + + // Second event: 3 damage from b1 to P0 → goes through (no shield left). + let p0_now = runner.life(P0); + let mut events = Vec::new(); + deal_damage::resolve( + runner.state_mut(), + &damage_ability(b1, TargetRef::Player(P0), 3), + &mut events, + ) + .expect("b1's second damage resolves"); + assert_eq!( + runner.life(P0), + p0_now - 3, + "second b1 damage must go through — the one-shot shield was consumed" + ); +} + +/// CR 120.8 + CR 609.7b (fix round 2, review #7334): a 0-damage event must +/// not consume the one-shot shield and must not fire the "damage prevented +/// this way" rider. A 0-damage source deals no damage at all (CR 120.8) — the +/// prevention has no event to replace — and a shield that prevents no damage +/// is not used up (CR 609.7b). This test drives the replacement pipeline with +/// a genuine 0-amount `ProposedEvent::Damage` from the chosen creature (the +/// public `replace_event` seam, reached by `deal_damage`'s gate on every +/// production path), then proves the shield still stops a later 3-damage event +/// with the rider firing exactly once (+3, and the 0-damage pass produced no +/// life gain and no `DamagePrevented` event). +#[test] +fn awe_strike_zero_damage_event_does_not_consume_shield_or_fire_rider() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let b1 = scenario.add_creature(P1, "B1", 3, 3).id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Awe Strike", true, AWE_STRIKE) + .id(); + + let mut runner = scenario.build(); + let p0_before = runner.life(P0); + let _outcome = runner.cast(spell).target_objects(&[b1]).resolve(); + let shield = runner + .state() + .pending_damage_replacements + .iter() + .find(|r| r.shield_kind == ShieldKind::PreventionOneShot) + .expect("the one-shot shield must be installed"); + assert!( + !shield.is_consumed, + "reach guard: the shield starts unconsumed" + ); + + // Zero-damage event from the chosen creature (CR 120.8: deals no damage). + let mut events = Vec::new(); + let result = engine::game::replacement::replace_event( + runner.state_mut(), + engine::types::proposed_event::ProposedEvent::Damage { + source_id: b1, + target: TargetRef::Player(P0), + amount: 0, + is_combat: false, + applied: std::collections::HashSet::new(), + }, + &mut events, + ); + assert!( + matches!( + result, + engine::game::replacement::ReplacementResult::Execute(_) + ), + "a 0-damage event must pass through unmodified, got {result:?}" + ); + assert_eq!( + runner.life(P0), + p0_before, + "a 0-damage event must not change life (no prevention, no rider gain)" + ); + assert!( + !events + .iter() + .any(|e| matches!(e, GameEvent::DamagePrevented { .. })), + "a 0-damage event must not emit DamagePrevented" + ); + let shield = runner + .state() + .pending_damage_replacements + .iter() + .find(|r| r.shield_kind == ShieldKind::PreventionOneShot) + .expect("the shield must still exist after the 0-damage event"); + assert!( + !shield.is_consumed, + "CR 609.7b: a shield that prevented no damage must not be used up" + ); + + // The shield survives: the next nonzero event from the chosen creature is + // still prevented and the rider fires exactly once (+3). + let p0_now = runner.life(P0); + let mut events = Vec::new(); + deal_damage::resolve( + runner.state_mut(), + &damage_ability(b1, TargetRef::Player(P0), 3), + &mut events, + ) + .expect("b1's 3-damage resolves"); + assert_eq!( + runner.life(P0), + p0_now + 3, + "the surviving shield must still prevent the later 3-damage event and \ + gain life equal to the damage prevented this way" + ); + assert!( + events + .iter() + .any(|e| matches!(e, GameEvent::DamagePrevented { amount: 3, .. })), + "the 3-damage event must emit DamagePrevented(3)" + ); + let shield = runner + .state() + .pending_damage_replacements + .iter() + .find(|r| r.shield_kind == ShieldKind::PreventionOneShot); + assert!( + shield.is_none() || shield.is_some_and(|r| r.is_consumed), + "after the 3-damage prevention the one-shot shield must be consumed (CR 615.3)" + ); +} + +/// CR 609.7a + CR 609.7b: the shield binds to the CHOSEN creature only — a +/// different source's damage goes through untouched, and the shield survives. +#[test] +fn awe_strike_shield_binds_to_chosen_creature_only() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let b1 = scenario.add_creature(P1, "B1", 3, 3).id(); + let b2 = scenario.add_creature(P1, "B2", 3, 3).id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Awe Strike", true, AWE_STRIKE) + .id(); + + let mut runner = scenario.build(); + let p0_before = runner.life(P0); + let _outcome = runner.cast(spell).target_objects(&[b1]).resolve(); + + let mut events = Vec::new(); + deal_damage::resolve( + runner.state_mut(), + &damage_ability(b2, TargetRef::Player(P0), 3), + &mut events, + ) + .expect("b2's damage resolves"); + assert_eq!( + runner.life(P0), + p0_before - 3, + "a non-chosen creature's damage must NOT be prevented" + ); + assert!( + !events + .iter() + .any(|e| matches!(e, GameEvent::DamagePrevented { .. })), + "no prevention event may fire for a non-chosen source" + ); +} + +/// CR 510.2 + CR 615.5 + CR 615.3: within a simultaneous combat-damage batch +/// (driven through the real combat step), the one-shot shield prevents the +/// single matching event and the rider fires exactly once (+3, not +6). +#[test] +fn awe_strike_prevents_combat_damage_batch_with_single_rider_fire() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // P0 controls the attacker and casts Awe Strike (P0 is the active player + // with priority in PreCombatMain); the attacker attacks P1. + let b1 = scenario.add_creature(P0, "B1", 3, 3).id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Awe Strike", true, AWE_STRIKE) + .id(); + + let mut runner = scenario.build(); + let p0_before = runner.life(P0); + let _outcome = runner.cast(spell).target_objects(&[b1]).resolve(); + // Sanity: the shield is installed in the pending registry. + assert!( + runner + .state() + .pending_damage_replacements + .iter() + .any(|r| r.shield_kind.is_shield()), + "the one-shot shield must be installed before combat" + ); + + // Drive b1 through a real combat step: it attacks P1 unblocked, dealing 3 + // combat damage in a simultaneous batch (CR 510.2). Manual driver — + // `advance_to_combat` auto-passes the DeclareAttackers action (declaring + // no attackers), so pass priority manually until the prompt surfaces. + let mut attacked = false; + let mut blocked = false; + for _ in 0..64 { + match runner.state().waiting_for.clone() { + engine::types::game_state::WaitingFor::Priority { .. } => { + if runner + .act(engine::types::actions::GameAction::PassPriority) + .is_err() + { + break; + } + } + engine::types::game_state::WaitingFor::DeclareAttackers { .. } if !attacked => { + attacked = true; + if runner + .declare_attackers(&[(b1, engine::game::combat::AttackTarget::Player(P1))]) + .is_err() + { + break; + } + } + engine::types::game_state::WaitingFor::DeclareAttackers { .. } => { + if runner.declare_attackers(&[]).is_err() { + break; + } + } + engine::types::game_state::WaitingFor::DeclareBlockers { .. } if !blocked => { + blocked = true; + if runner.declare_blockers(&[]).is_err() { + break; + } + } + engine::types::game_state::WaitingFor::DeclareBlockers { .. } => { + if runner.declare_blockers(&[]).is_err() { + break; + } + } + engine::types::game_state::WaitingFor::OrderTriggers { .. } => { + if runner + .act(engine::types::actions::GameAction::OrderTriggers { order: vec![0] }) + .is_err() + { + break; + } + } + _ => break, + } + } + runner.advance_until_stack_empty(); + + assert_eq!( + runner.life(P0), + p0_before + 3, + "the combat damage is prevented and the rider gains +3 exactly once \ + (a double fire would gain +6); got {}", + runner.life(P0) + ); +} + +/// CR 609.7 + CR 614.1a: the shield is source-scoped, so the chosen creature's +/// damage to a CREATURE recipient is prevented too. +#[test] +fn awe_strike_prevents_chosen_sources_damage_to_creature_recipient() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let b1 = scenario.add_creature(P1, "B1", 3, 3).id(); + let victim = scenario.add_creature(P0, "Victim", 2, 2).id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Awe Strike", true, AWE_STRIKE) + .id(); + + let mut runner = scenario.build(); + let _outcome = runner.cast(spell).target_objects(&[b1]).resolve(); + + let mut events = Vec::new(); + deal_damage::resolve( + runner.state_mut(), + &damage_ability(b1, TargetRef::Object(victim), 2), + &mut events, + ) + .expect("b1's damage to the creature resolves"); + assert_eq!( + runner.state().objects[&victim].damage_marked, + 0, + "the chosen creature's damage to a creature recipient must be prevented" + ); +} + +/// CR 115.1 + CR 601.2c + CR 609.7a: the cast surfaces exactly one target +/// slot (the chosen creature) and the installed shield's resolved source +/// filter is `And { [SpecificObject(b1), Typed(creature)] }` — a source-scoped +/// shield, not a blanket prevent-all, and not hosted on the creature. +#[test] +fn awe_strike_cast_surfaces_single_creature_slot_and_source_scoped_shield() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let b1 = scenario.add_creature(P1, "B1", 3, 3).id(); + let b2 = scenario.add_creature(P1, "B2", 3, 3).id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Awe Strike", true, AWE_STRIKE) + .id(); + + let mut runner = scenario.build(); + let outcome = runner.cast(spell).target_objects(&[b1]).resolve(); + + let state = outcome.state(); + let shield = state + .pending_damage_replacements + .iter() + .find(|r| r.shield_kind.is_shield()) + .expect("the one-shot shield must be installed in the pending registry"); + assert_eq!( + shield.shield_kind, + ShieldKind::PreventionOneShot, + "the shield must be the one-shot prevention kind" + ); + assert_eq!( + shield.damage_source_filter, + Some(TargetFilter::And { + filters: vec![ + TargetFilter::SpecificObject { id: b1 }, + TargetFilter::Typed(engine::types::ability::TypedFilter::creature()), + ], + }), + "the shield must bind to the chosen creature (CR 609.7a) with the typed \ + CR 609.7b recheck leaf — not a blanket prevent-all" + ); + // Not hosted on either creature as a recipient. + assert!( + !state.objects[&b1] + .replacement_definitions + .as_slice() + .iter() + .any(|r| r.shield_kind.is_shield()), + "the source-scoped shield must not be hosted on the chosen creature as a recipient" + ); + assert!( + !state.objects[&b2] + .replacement_definitions + .as_slice() + .iter() + .any(|r| r.shield_kind.is_shield()), + "the shield must not be hosted on the unchosen creature" + ); +} + +/// CR 615.5: Dazzling Reflection — "You gain life equal to target creature's +/// power. The next time that creature would deal damage this turn, prevent +/// that damage." The rider gains the target creature's power immediately, and +/// the "that creature" one-shot prevention binds to the chosen creature. +#[test] +fn dazzling_reflection_target_power_gain_and_that_creature_prevent() { + const DAZZLING_REFLECTION: &str = "You gain life equal to target creature's power. The next time that creature would deal damage this turn, prevent that damage."; + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let b1 = scenario.add_creature(P1, "B1", 5, 5).id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Dazzling Reflection", true, DAZZLING_REFLECTION) + .id(); + + let mut runner = scenario.build(); + let p0_before = runner.life(P0); + let _outcome = runner.cast(spell).target_objects(&[b1]).resolve(); + + // The first instruction gains life equal to b1's power. + assert_eq!( + runner.life(P0), + p0_before + 5, + "Dazzling Reflection's own gain-life must equal the target creature's power (5)" + ); + + // The one-shot prevention still protects the chosen creature. + let mut events = Vec::new(); + deal_damage::resolve( + runner.state_mut(), + &damage_ability(b1, TargetRef::Player(P0), 2), + &mut events, + ) + .expect("b1's damage resolves"); + assert_eq!( + runner.life(P0), + p0_before + 5, + "the 'that creature' one-shot prevention must still prevent b1's damage" + ); +} + +/// CR 609.7 + CR 615.3: a Dromoka's Command source-scoped prevention shield +/// (Typed(instant|sorcery) leaf) must NOT become one-shot — its +/// `Prevention { All }` shield stays continuous. +/// +/// Positioning note (discriminator-precision guard, one-sided): this test is +/// the NEGATIVE side of the exact-shape discriminator +/// (`is_oneshot_target_source_prevent_shape`) — it proves the shape predicate +/// REJECTS a `Typed(instant|sorcery)` leaf, so a too-wide discriminator +/// (any `ParentTargetSlot` + any creature-containing `Typed`) fails here. +/// It does NOT by itself prove the positive side: if the whole one-shot +/// machinery were reverted, this test still passes (a missing discriminator +/// trivially leaves `Prevention { All }`). The positive side is pinned by +/// `awe_strike_cast_surfaces_single_creature_slot_and_source_scoped_shield` +/// and `awe_strike_prevents_first_source_damage_once_then_lets_second_through` +/// (both fail on revert — verified). A positive in-test assertion that the +/// shared predicate accepts the Awe Strike shape is added below to keep the +/// two sides of the same predicate in one place. +#[test] +fn dromokas_command_source_scoped_shield_is_not_consumed() { + const DROMOKAS: &str = "Choose two —\n\ + • Prevent all damage target instant or sorcery spell would deal this turn.\n\ + • Target player sacrifices an enchantment.\n\ + • Put a +1/+1 counter on target creature.\n\ + • Target creature you control fights target creature you don't control."; + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let my_creature = scenario.add_creature(P0, "Bear", 2, 2).id(); + let instant = scenario + .add_spell_to_hand_from_oracle( + P0, + "Damage Instant", + true, + "This spell deals 3 damage to target player.", + ) + .id(); + let dromoka = scenario + .add_spell_to_hand_from_oracle(P0, "Dromoka's Command", true, DROMOKAS) + .id(); + + let mut runner = scenario.build(); + // Cast the instant, answer its player target, leave it on the stack. + let instant_card = runner.state().objects[&instant].card_id; + runner + .act(engine::types::actions::GameAction::CastSpell { + object_id: instant, + card_id: instant_card, + targets: vec![], + payment_mode: engine::types::game_state::CastPaymentMode::Auto, + }) + .expect("cast the damage instant"); + if let engine::types::game_state::WaitingFor::TargetSelection { .. } = + runner.state().waiting_for.clone() + { + let _ = runner.act(engine::types::actions::GameAction::SelectTargets { + targets: vec![TargetRef::Player(P0)], + }); + } + let _outcome = runner + .cast(dromoka) + .modes(&[0, 2]) + .target_objects(&[instant, my_creature]) + .resolve(); + + let shield = runner + .state() + .pending_damage_replacements + .iter() + .find(|r| { + matches!( + &r.damage_source_filter, + Some(TargetFilter::And { filters }) + if filters + .iter() + .any(|f| matches!(f, TargetFilter::SpecificObject { .. })) + ) + }) + .expect("the source-scoped shield must exist"); + assert_eq!( + shield.shield_kind, + ShieldKind::Prevention { + amount: engine::types::ability::PreventionAmount::All + }, + "Dromoka's Command must install a continuous Prevention All shield, not one-shot" + ); + assert!( + !shield.consume_on_apply, + "Dromoka's Command shield must not be consume-on-apply" + ); + // Positive side of the same predicate, in the same test: the shared + // exact-shape discriminator ACCEPTS the Awe Strike source-filter shape + // (And{[ParentTargetSlot{0}, Typed(creature)]}) — proving the negative + // assertions above exercise a live predicate that does discriminate. + assert!( + engine::types::ability::is_oneshot_target_source_prevent_shape(&TargetFilter::And { + filters: vec![ + TargetFilter::ParentTargetSlot { index: 0 }, + TargetFilter::Typed(engine::types::ability::TypedFilter::creature()), + ], + }), + "the shared shape predicate must accept the Awe Strike one-shot shape" + ); + // And the predicate must REJECT the Dromoka leaf (a creature-containing + // `Typed` is not enough — the type list must be exactly [Creature]). + assert!( + !engine::types::ability::is_oneshot_target_source_prevent_shape(&TargetFilter::And { + filters: vec![ + TargetFilter::ParentTargetSlot { index: 0 }, + TargetFilter::Typed( + engine::types::ability::TypedFilter::new( + engine::types::ability::TypeFilter::AnyOf(vec![ + engine::types::ability::TypeFilter::Instant, + engine::types::ability::TypeFilter::Sorcery, + ]) + ) + .with_type(engine::types::ability::TypeFilter::Creature), + ), + ], + }), + "the shared shape predicate must reject a Typed leaf that is not exactly [Creature]" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 96e8eccd00..f3418a9af8 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -48,6 +48,7 @@ mod aura_graft_enchant_restriction; mod aura_on_player; mod aurification_gold_counter_defender_cant_attack; mod awaken_runtime; +mod awe_strike_prevention; mod azors_gateway_transform_condition; mod backup_becomes_target_trigger; mod balance_equalization;