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
31 changes: 18 additions & 13 deletions crates/engine/src/database/synthesis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3703,16 +3703,19 @@ pub(crate) fn entry_replacement_for_grant_static(

/// CR 702.64a: Absorb N — "If a source would deal damage to this creature,
/// prevent N of that damage." A continuous, self-recipient damage replacement:
/// `DamageModification::Minus { value: N }` saturating-subtracts N from each
/// damage event whose recipient is this creature (`valid_card: SelfRef`). It is
/// NOT a consumed shield, so it re-applies to every source and every event
/// independently (CR 702.64b). No new variant — mirrors the continuous
/// damage-prevention statics (Benevolent Unicorn class) and the self-scoped
/// `valid_card(SelfRef)` damage replacements (persistent prevention shields).
/// `DamageModification::PreventionMinus { value: N }` saturating-subtracts N
/// from each damage event whose recipient is this creature (`valid_card:
/// SelfRef`). `PreventionMinus` is the CR 615 prevention provenance of the
/// shared `Minus` subtraction authority — Absorb genuinely PREVENTS damage, so
/// it emits `DamagePrevented` bookkeeping, unlike the plain-arithmetic
/// `Minus` statics (Benevolent Unicorn class). It is NOT a consumed shield, so
/// it re-applies to every source and every event independently (CR 702.64b),
/// like the self-scoped `valid_card(SelfRef)` damage replacements (persistent
/// prevention shields).
fn build_absorb_replacement(n: u32) -> ReplacementDefinition {
ReplacementDefinition::new(ReplacementEvent::DamageDone)
.valid_card(TargetFilter::SelfRef)
.damage_modification(DamageModification::Minus { value: n })
.damage_modification(DamageModification::PreventionMinus { value: n })
.description(format!(
"CR 702.64a: Absorb {n} — if a source would deal damage to this creature, \
prevent {n} of that damage."
Expand All @@ -3728,7 +3731,7 @@ fn is_absorb_replacement(r: &ReplacementDefinition, n: u32) -> bool {
&& matches!(r.valid_card, Some(TargetFilter::SelfRef))
&& matches!(
r.damage_modification,
Some(DamageModification::Minus { value }) if value == n
Some(DamageModification::PreventionMinus { value }) if value == n
)
}

Expand Down Expand Up @@ -25200,9 +25203,11 @@ mod absorb_synthesis_tests {
//! CR 702.64a shape tests: Absorb was parsed/typed but had no runtime.
//! `synthesize_absorb` installs a continuous self-recipient `DamageDone`
//! replacement that subtracts N from each incoming damage event
//! (`DamageModification::Minus { value: N }`, `valid_card: SelfRef`). The
//! continuous, non-consumed, per-source/per-event semantics (CR 702.64b) come
//! for free from `Minus`; CR 702.64c (each instance separate) is one
//! (`DamageModification::PreventionMinus { value: N }` — the CR 615
//! prevention provenance of the shared `Minus` subtraction —
//! `valid_card: SelfRef`). The continuous, non-consumed,
//! per-source/per-event semantics (CR 702.64b) come for free from the
//! shared subtraction arm; CR 702.64c (each instance separate) is one
//! replacement per instance.
use super::*;
use crate::game::effects::deal_damage;
Expand Down Expand Up @@ -25287,9 +25292,9 @@ mod absorb_synthesis_tests {
assert!(
matches!(
r.damage_modification,
Some(DamageModification::Minus { value: 2 })
Some(DamageModification::PreventionMinus { value: 2 })
),
"CR 702.64a: prevent N (=2) of the damage"
"CR 702.64a: prevent N (=2) of the damage (prevention provenance)"
);
}

Expand Down
221 changes: 215 additions & 6 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,9 @@ fn damage_done_applier(
) -> ApplyResult {
// Branch 1: Damage modification (Double, Triple, Plus, Minus)
if let Some(modification) = damage_modification_for_rid(state, rid) {
// CR 510.2: identity for the combat-damage-batch prevention tally, taken
// before the event is destructured (mirrors the Branch 2 shield path).
let applied_key = AppliedReplacementKey::for_event(&event, rid);
if let ProposedEvent::Damage {
source_id,
target,
Expand All @@ -1556,6 +1559,15 @@ fn damage_done_applier(
applied,
} = event
{
// CR 615.1a: typed prevention provenance, captured before the match
// consumes `modification` (the `Plus`/`SetTo` arms move their
// non-`Copy` payload out). ONLY `PreventionMinus` — the CR 615
// prevention provenance of the shared subtraction — does prevention
// bookkeeping below; plain arithmetic `Minus` (Benevolent Unicorn's
// "that much damage minus 1") reduces the amount without preventing
// anything.
let is_minus_prevention =
matches!(modification, DamageModification::PreventionMinus { .. });
let new_amount = match modification {
DamageModification::Double => amount.saturating_mul(2),
DamageModification::Triple => amount.saturating_mul(3),
Expand Down Expand Up @@ -1593,10 +1605,16 @@ fn damage_done_applier(
.max(0) as u32;
amount.saturating_add(added)
}
// CR 615.1 + CR 614.1a: Saturating subtract. `Minus { value: u32::MAX }`
// is the continuous prevent-all sentinel — yields 0 for any amount and
// is not consumed (continuous, not shield-style).
DamageModification::Minus { value } => amount.saturating_sub(value),
// CR 615.1 + CR 614.1a: Saturating subtract — the ONE shared
// subtraction authority for both provenances. `Minus` is plain
// arithmetic (CR 614.1a); `PreventionMinus` is CR 615 prevention
// provenance over the identical formula
// (`PreventionMinus { value: u32::MAX }` is the continuous
// prevent-all sentinel — yields 0 for any amount and is not
// consumed; continuous, not shield-style). Only the prevention
// provenance does the `DamagePrevented` bookkeeping below.
DamageModification::Minus { value }
| DamageModification::PreventionMinus { value } => amount.saturating_sub(value),
// CR 614.1a: Conditional — if amount < source's power, set to power.
// References the replacement source's (rid.source) post-layer power.
DamageModification::SetToSourcePower => {
Expand Down Expand Up @@ -1644,6 +1662,57 @@ fn damage_done_applier(
if let Some(ShieldKind::DamageReplacementOneShot) = shield_kind_for_rid(state, rid) {
consume_prevention_shield(state, rid, None);
}
// CR 615.1a + CR 702.64b + CR 510.2: `PreventionMinus` is the typed
// prevention provenance of the shared `Minus` subtraction — CR 702.64
// Absorb, the bare "prevent N of that damage" statics (Heart-Shaped
// Herb #5902, Sphere of Purity, Orbs of Warding, ...), and the
// `PreventionMinus { value: u32::MAX }` prevent-all sentinel. When it
// actually reduces the event it prevents damage, so it performs the
// same bookkeeping the `ShieldKind::Prevention` shields do (Branch 2),
// with the same per-event vs post-batch binding semantics:
// * outside a combat-damage batch, emit `DamagePrevented` per event
// and stamp the per-event prevented amount into
// `last_effect_count` so a "damage prevented this way"
// continuation resolves `QuantityRef::EventContextAmount` against
// THIS event's amount (CR 615.5; mirrors Branch 2's stamp);
// * inside a batch, accumulate into the per-replacement tally — the
// single `DamagePrevented` and the aggregate `last_effect_count`
// stamp happen post-batch in `fire_combat_prevention_riders`
// (CR 510.2 + CR 615.13), so nothing is emitted or stamped here;
// * exception (mirrors Branch 2): an `execute`-template follow-up
// drains per-event inside `replace_combat_damage_batch` and needs
// the per-event amount stamped even while the batch tally is
// active; a per-source-reflecting rider (Comeuppance class) must
// never aggregate at all.
// Plain arithmetic `Minus` and the increase/no-op modifications
// (Double, Triple, Plus, SetTo*, LifeFloor) are not prevention and
// record nothing.
if is_minus_prevention {
let prevented = amount.saturating_sub(new_amount);
if prevented > 0 {
let mut accumulated_in_batch = false;
if !shield_rider_reflects_per_event(state, rid) {
if let Some(tally) = state.combat_prevention_tally.as_mut() {
*tally.entry(applied_key).or_insert(0) += prevented as i32;
accumulated_in_batch = true;
}
}
let per_event_execute_followup =
accumulated_in_batch && shield_has_per_event_execute_followup(state, rid);
if !accumulated_in_batch || per_event_execute_followup {
if !accumulated_in_batch {
events.push(GameEvent::DamagePrevented {
source_id,
target: target.clone(),
amount: prevented,
});
}
// CR 615.5: the prevented-amount handoff for follow-up
// continuations — identical to Branch 2's stamp.
state.last_effect_count = Some(prevented as i32);
}
}
}
return ApplyResult::Modified(ProposedEvent::Damage {
source_id,
target,
Expand Down Expand Up @@ -4715,7 +4784,15 @@ fn is_damage_prevention_replacement(
return false;
};

// CR 614.1a: Damage boost/reduction replacements are definitively not prevention effects
// Ordinary damage modifications are not prevention, but `PreventionMinus`
// carries explicit prevention provenance and must be suppressed when damage
// can't be prevented.
if matches!(
repl.damage_modification,
Some(DamageModification::PreventionMinus { .. })
) {
return true;
}
if repl.damage_modification.is_some() {
return false;
}
Expand Down Expand Up @@ -7852,7 +7929,10 @@ fn damage_commute_class(modification: &DamageModification) -> CommuteClass {
match modification {
DamageModification::Double | DamageModification::Triple => CommuteClass::Multiplicative,
DamageModification::Plus { .. } => CommuteClass::Additive,
DamageModification::Minus { .. } => CommuteClass::Subtractive,
// CR 616.1: both provenances of the shared subtraction commute alike.
DamageModification::Minus { .. } | DamageModification::PreventionMinus { .. } => {
CommuteClass::Subtractive
}
DamageModification::SetToSourcePower
| DamageModification::SetTo { .. }
| DamageModification::LifeFloor { .. } => CommuteClass::NonCommuting,
Expand Down Expand Up @@ -13881,6 +13961,135 @@ mod tests {
}
}

/// CR 614.1a vs CR 615: plain arithmetic `Minus` (Benevolent Unicorn's
/// "that much damage minus 1") is NOT prevention provenance — it must
/// reduce the amount WITHOUT emitting `DamagePrevented` and WITHOUT
/// stamping the CR 615.5 prevented-amount handoff. (Regression for the
/// review finding that every `Minus` was classified as prevention.)
#[test]
fn damage_applier_arithmetic_minus_is_not_prevention() {
let repl = damage_repl(DamageModification::Minus { value: 1 });
let mut state = test_state_with_damage_repl(ObjectId(10), PlayerId(0), vec![repl]);
let mut events = Vec::new();
let rid = ReplacementId {
source: ObjectId(10),
index: 0,
};
let result = damage_done_applier(damage_event(3), rid, &mut state, &mut events);
match result {
ApplyResult::Modified(ProposedEvent::Damage { amount, .. }) => {
assert_eq!(amount, 2, "arithmetic Minus must still subtract");
}
other => panic!("Expected Modified Damage, got {other:?}"),
}
assert!(
!events
.iter()
.any(|e| matches!(e, GameEvent::DamagePrevented { .. })),
"arithmetic Minus prevents nothing — no DamagePrevented may be emitted"
);
assert_eq!(
state.last_effect_count, None,
"arithmetic Minus must not stamp the CR 615.5 prevented-amount handoff"
);
}

/// CR 615.1a + CR 615.5: the `PreventionMinus` provenance of the shared
/// subtraction must, OUTSIDE a combat batch, emit `DamagePrevented` for the
/// per-event prevented amount AND stamp it into `last_effect_count` so a
/// "damage prevented this way" continuation resolves
/// `QuantityRef::EventContextAmount` against THIS event's amount (mirrors
/// the Branch 2 shield stamp). Seeded with a stale count to prove the
/// binding overwrites it — without the stamp the continuation would read
/// the stale 999.
#[test]
fn damage_applier_prevention_minus_stamps_per_event_amount_for_continuations() {
let repl = damage_repl(DamageModification::PreventionMinus { value: 2 });
let mut state = test_state_with_damage_repl(ObjectId(10), PlayerId(0), vec![repl]);
state.last_effect_count = Some(999);
let mut events = Vec::new();
let rid = ReplacementId {
source: ObjectId(10),
index: 0,
};
let result = damage_done_applier(damage_event(5), rid, &mut state, &mut events);
match result {
ApplyResult::Modified(ProposedEvent::Damage { amount, .. }) => {
assert_eq!(amount, 3, "PreventionMinus(2) must subtract from 5");
}
other => panic!("Expected Modified Damage, got {other:?}"),
}
assert!(
events
.iter()
.any(|e| matches!(e, GameEvent::DamagePrevented { amount: 2, .. })),
"prevention provenance must emit DamagePrevented for the prevented 2"
);
assert_eq!(
state.last_effect_count,
Some(2),
"the per-event prevented amount must be stamped for the rider handoff"
);
// The continuation's view: resolve the prevented amount through the real
// quantity resolver, exactly as a "for each 1 damage prevented this way"
// rider would (`current_trigger_event` is None here, so the documented
// `last_effect_count` fallback is the read path).
let observed = crate::game::quantity::resolve_quantity(
&state,
&QuantityExpr::Ref {
qty: crate::types::ability::QuantityRef::EventContextAmount,
},
PlayerId(0),
ObjectId(10),
);
assert_eq!(
observed, 2,
"a prevented-amount continuation must observe the per-event amount"
);
}

/// CR 510.2 + CR 615.13: inside a combat-damage batch, `PreventionMinus`
/// must defer BOTH the `DamagePrevented` emission and the
/// `last_effect_count` stamp to the post-batch aggregate — it accumulates
/// into the per-replacement tally that `fire_combat_prevention_riders`
/// consumes (which emits the single event and stamps the batch total),
/// mirroring the `Prevention::All` shield batching.
#[test]
fn damage_applier_prevention_minus_in_batch_defers_to_post_batch_aggregate() {
let repl = damage_repl(DamageModification::PreventionMinus { value: 2 });
let mut state = test_state_with_damage_repl(ObjectId(10), PlayerId(0), vec![repl]);
state.combat_prevention_tally = Some(HashMap::new());
let mut events = Vec::new();
let rid = ReplacementId {
source: ObjectId(10),
index: 0,
};
let result = damage_done_applier(damage_event(5), rid, &mut state, &mut events);
match result {
ApplyResult::Modified(ProposedEvent::Damage { amount, .. }) => {
assert_eq!(amount, 3);
}
other => panic!("Expected Modified Damage, got {other:?}"),
}
assert!(
!events
.iter()
.any(|e| matches!(e, GameEvent::DamagePrevented { .. })),
"in-batch prevention must not emit per-source DamagePrevented (deferred)"
);
assert_eq!(
state.last_effect_count, None,
"in-batch prevention must not stamp per-event — the aggregate stamp \
happens post-batch so the rider sees the un-fragmented total"
);
let tally = state.combat_prevention_tally.as_ref().unwrap();
assert_eq!(
tally.values().copied().collect::<Vec<_>>(),
vec![2],
"the prevented amount must accumulate into the per-replacement batch tally"
);
}

#[test]
fn damage_applier_life_floor_does_not_increase_damage() {
let repl = damage_repl(DamageModification::LifeFloor { minimum: 1 });
Expand Down
Loading
Loading