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
3 changes: 2 additions & 1 deletion crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
Expand Down
42 changes: 41 additions & 1 deletion crates/engine/src/game/effects/prevent_damage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
Expand Down
107 changes: 104 additions & 3 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// No modification and no prevention shield — pass through
ApplyResult::Modified(event)
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 51 additions & 3 deletions crates/engine/src/parser/oracle_effect/assembly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
};
Expand Down
3 changes: 2 additions & 1 deletion crates/engine/src/parser/oracle_effect/imperative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
7 changes: 6 additions & 1 deletion crates/engine/src/parser/oracle_ir/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ pub(crate) struct ParseContext {
#[allow(dead_code)] // Retained for future nom combinator consumers (D-02).
pub quantity_ref: Option<QuantityRef>,
/// 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).
Expand Down
Loading
Loading