Skip to content
9 changes: 9 additions & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1952,6 +1952,7 @@ fn legacy_static_condition(x: &StaticCondition) -> bool {
| StaticCondition::SourceIsTapped
| StaticCondition::OpponentPoisonAtLeast { .. }
| StaticCondition::SpellCastWithVariantThisTurn { .. }
| StaticCondition::AnyPlayerAttackedYouLastTurn
| StaticCondition::SourceMatchesFilter { .. }
| StaticCondition::TopOfLibraryMatches { .. }
| StaticCondition::UnlessPay { .. }
Expand Down Expand Up @@ -6281,6 +6282,14 @@ fn rw_static_condition(x: &StaticCondition) -> RwProfile {
StaticCondition::SpellCastWithVariantThisTurn { .. } => {
reads_player_of(StateKind::JournalCast)
}
// CR 508.6 + CR 514.2: reads the cleanup-time attack-history snapshot
// (`attacked_defenders_last_turn`), which changes only at turn boundaries.
// `TurnStructure` is the sequencing kind written by cleanup/turn advance;
// conservatively depending on it invalidates the cached gate whenever the
// turn sequence changes.
StaticCondition::AnyPlayerAttackedYouLastTurn => {
reads_player_of(StateKind::TurnStructure)
}
StaticCondition::SourceMatchesFilter { filter: _ } => reads_src_of(StateKind::ObjectPt),
// CR 401/402: reads the controller's library top card (contents + order).
// A draw/scry/surveil/mill/shuffle writes `HandLibrary`, so marking this
Expand Down
7 changes: 7 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3563,6 +3563,13 @@ fn scan_static_condition(x: &StaticCondition, mode: ScanMode) -> Axes {
sibling: false,
projected: true,
},
// CR 508.6: turn-history projection over the cleanup-time attack snapshot;
// mirrors `SpellCastWithVariantThisTurn` (projected, not event/sibling).
StaticCondition::AnyPlayerAttackedYouLastTurn => Axes {
event: false,
sibling: false,
projected: true,
},
StaticCondition::OpponentPoisonAtLeast { count: _ } => Axes {
event: false,
sibling: false,
Expand Down
135 changes: 135 additions & 0 deletions crates/engine/src/game/casting_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2766,6 +2766,141 @@ fn visions_of_ruin_flashback_commander_mv_reduces_flashback_cost() {
}
}

/// CR 508.6 + CR 109.5: Avenge — "This spell costs {2} less to cast if a player
/// attacked you during their last turn." The self-spell `ModifyCost` reduction
/// must fire ONLY when the revenge gate holds. Drives the real cost pipeline
/// (`prepare_spell_cast` → `collect_self_spell_cost_modifiers` →
/// `self_spell_cost_condition_matches` → `layers::evaluate_condition`). The
/// empty-snapshot assertion below is the revert guard: with the fix reverted the
/// dropped condition would make the reduction unconditional and this case would
/// wrongly report generic 2 instead of 4.
#[test]
fn avenge_cost_reduction_gated_on_attacked_you_last_turn() {
use crate::types::ability::Effect;

// Build an Avenge-shaped Sorcery ({4}{W}{W}) in hand whose self-spell
// `ModifyCost` reduces the generic cost by {2}, gated on the revenge predicate.
fn setup_avenge() -> (GameState, ObjectId) {
let mut state = setup_game_at_main_phase();
let spell = create_object(
&mut state,
CardId(9101),
PlayerId(0),
"Avenge".to_string(),
Zone::Hand,
);
{
let obj = state.objects.get_mut(&spell).unwrap();
obj.card_types.core_types.push(CoreType::Sorcery);
obj.mana_cost = ManaCost::Cost {
shards: vec![ManaCostShard::White, ManaCostShard::White],
generic: 4,
};
Arc::make_mut(&mut obj.abilities).push(AbilityDefinition::new(
AbilityKind::Spell,
Effect::Draw {
count: QuantityExpr::Fixed { value: 1 },
target: TargetFilter::Controller,
},
));
let mut def = StaticDefinition::new(StaticMode::ModifyCost {
mode: CostModifyMode::Reduce,
amount: ManaCost::generic(2),
spell_filter: None,
dynamic_count: None,
})
.affected(TargetFilter::SelfRef)
.condition(StaticCondition::AnyPlayerAttackedYouLastTurn);
def.active_zones = crate::types::zones::self_spell_cost_mod_active_zones();
obj.static_definitions.push(def);
}
(state, spell)
}

// Total generic cost the caster (P0) would pay for the prepared spell; the two
// white shards are asserted invariant so only the {2} generic reduction moves.
fn prepared_generic(state: &GameState, spell: ObjectId) -> u32 {
match prepare_spell_cast(state, PlayerId(0), spell)
.unwrap()
.mana_cost
{
ManaCost::Cost { generic, shards } => {
assert_eq!(shards, vec![ManaCostShard::White, ManaCostShard::White]);
generic
}
other => panic!("expected ManaCost::Cost, got {other:?}"),
}
}

// Positive: an opponent (P1) attacked you (P0) last turn ⇒ {2} reduction fires.
let (mut state, spell) = setup_avenge();
state
.attacked_defenders_last_turn
.insert(PlayerId(1), [PlayerId(0)].into_iter().collect());
assert_eq!(
prepared_generic(&state, spell),
2,
"gate holds ⇒ reduced to {{2}}{{W}}{{W}}"
);

// Empty (paired negative / revert guard): no attack recorded ⇒ full cost.
let (state, spell) = setup_avenge();
assert_eq!(
prepared_generic(&state, spell),
4,
"no attack last turn ⇒ full {{4}}{{W}}{{W}} (reduction must be gated)"
);

// Direction / self-exclusion: YOU (P0) attacking an opponent last turn does
// NOT satisfy "a player attacked YOU" — the controller is skipped by the
// `p.id != controller` guard.
let (mut state, spell) = setup_avenge();
state
.attacked_defenders_last_turn
.insert(PlayerId(0), [PlayerId(1)].into_iter().collect());
assert_eq!(
prepared_generic(&state, spell),
4,
"you attacked an opponent ⇒ still full cost"
);
}

/// CR 508.6: the "attacked you during their last turn" gate is existential over
/// players — true when ANY non-controller player attacked you, false when none
/// did, and false when opponents attacked only each other. Multi-authority
/// (3-player) coverage that `layers::evaluate_condition` neither over- nor
/// under-matches.
#[test]
fn attacked_you_last_turn_condition_is_existential_over_players() {
use crate::game::layers::evaluate_condition_for_test;
use crate::types::format::FormatConfig;

let cond = StaticCondition::AnyPlayerAttackedYouLastTurn;
let you = PlayerId(0);
let src = ObjectId(0); // unused by this nullary, source-agnostic condition

// No one attacked you ⇒ false.
let state = GameState::new(FormatConfig::standard(), 3, 7);
assert!(!evaluate_condition_for_test(&state, &cond, you, src));

// Only P2 attacked you (P1 attacked no one) ⇒ true (existential over players).
let mut state = GameState::new(FormatConfig::standard(), 3, 7);
state
.attacked_defenders_last_turn
.insert(PlayerId(2), [you].into_iter().collect());
assert!(evaluate_condition_for_test(&state, &cond, you, src));

// Opponents attacked each other but not you ⇒ false (the defender must be you).
let mut state = GameState::new(FormatConfig::standard(), 3, 7);
state
.attacked_defenders_last_turn
.insert(PlayerId(1), [PlayerId(2)].into_iter().collect());
state
.attacked_defenders_last_turn
.insert(PlayerId(2), [PlayerId(1)].into_iter().collect());
assert!(!evaluate_condition_for_test(&state, &cond, you, src));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[test]
fn grant_next_spell_without_paying_casts_for_free() {
use super::super::engine::apply_as_current;
Expand Down
4 changes: 4 additions & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4231,6 +4231,7 @@ fn fmt_static_condition(cond: &StaticCondition) -> String {
SC::SpellCastWithVariantThisTurn { .. } => {
"a spell was cast with this variant this turn".into()
}
SC::AnyPlayerAttackedYouLastTurn => "a player attacked you during their last turn".into(),
SC::OpponentPoisonAtLeast { count } => format!("an opponent has {count}+ poison"),
SC::UnlessPay { .. } => "unless a cost is paid".into(),
SC::Unrecognized { .. } => "unrecognized".into(),
Expand Down Expand Up @@ -7836,6 +7837,9 @@ fn static_condition_feature(cond: &StaticCondition) -> (&'static str, FeatureSup
StaticCondition::SpellCastWithVariantThisTurn { .. } => {
("SpellCastWithVariantThisTurn", Handled)
}
// CR 508.6: runtime-handled by `layers::evaluate_condition` over the
// cleanup-time attack snapshot (drives Avenge's cost reduction).
StaticCondition::AnyPlayerAttackedYouLastTurn => ("AnyPlayerAttackedYouLastTurn", Handled),
StaticCondition::OpponentPoisonAtLeast { .. } => ("OpponentPoisonAtLeast", Unhandled),
StaticCondition::UnlessPay { .. } => ("UnlessPay", Handled),
StaticCondition::ControlsCommander { .. } => ("ControlsCommander", Unhandled),
Expand Down
14 changes: 14 additions & 0 deletions crates/engine/src/game/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,7 @@ fn static_condition_uses_object_population(condition: &StaticCondition) -> bool
| StaticCondition::CompletedADungeon
| StaticCondition::WasStartingPlayer { .. }
| StaticCondition::SpellCastWithVariantThisTurn { .. }
| StaticCondition::AnyPlayerAttackedYouLastTurn
| StaticCondition::OpponentPoisonAtLeast { .. }
| StaticCondition::UnlessPay { .. }
| StaticCondition::DuringYourTurn
Expand Down Expand Up @@ -1252,6 +1253,7 @@ fn static_condition_characteristic_reads_at(
| StaticCondition::CompletedADungeon
| StaticCondition::WasStartingPlayer { .. }
| StaticCondition::SpellCastWithVariantThisTurn { .. }
| StaticCondition::AnyPlayerAttackedYouLastTurn
| StaticCondition::OpponentPoisonAtLeast { .. }
| StaticCondition::UnlessPay { .. }
| StaticCondition::DuringYourTurn
Expand Down Expand Up @@ -1375,6 +1377,7 @@ fn entered_object_perturbs_static_condition(
| StaticCondition::CompletedADungeon
| StaticCondition::WasStartingPlayer { .. }
| StaticCondition::SpellCastWithVariantThisTurn { .. }
| StaticCondition::AnyPlayerAttackedYouLastTurn
| StaticCondition::OpponentPoisonAtLeast { .. }
| StaticCondition::UnlessPay { .. }
| StaticCondition::DuringYourTurn
Expand Down Expand Up @@ -1606,6 +1609,16 @@ fn evaluate_condition_with_context(
StaticCondition::SpellCastWithVariantThisTurn { variant } => {
crate::game::restrictions::spell_cast_with_variant_this_turn(state, variant)
}
// CR 508.6 + CR 109.5: True when any non-eliminated player (other than the
// controller) declared a creature attacking the controller ("you") during
// that player's most recent completed turn. Existential; the defender is
// the controller, so a player who attacked someone else — or the
// controller's own attacks — do not satisfy it.
StaticCondition::AnyPlayerAttackedYouLastTurn => state.players.iter().any(|p| {
!p.is_eliminated
&& p.id != controller
&& state.player_attacked_player_last_turn(p.id, controller)
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// CR 105.2 + CR 611.3a: the subject is the recipient (the enchanted
// creature, "it"), not the Aura source; fall back to the source only when
// evaluated without a recipient (the source gate defers to per-recipient).
Expand Down Expand Up @@ -3448,6 +3461,7 @@ fn static_condition_reads_life(condition: &StaticCondition) -> bool {
| StaticCondition::CompletedADungeon
| StaticCondition::WasStartingPlayer { .. }
| StaticCondition::SpellCastWithVariantThisTurn { .. }
| StaticCondition::AnyPlayerAttackedYouLastTurn
| StaticCondition::OpponentPoisonAtLeast { .. }
| StaticCondition::UnlessPay { .. }
| StaticCondition::Unrecognized { .. }
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/quantity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,7 @@ pub(crate) fn static_condition_uses_unspent_mana(condition: &StaticCondition) ->
| StaticCondition::CompletedADungeon
| StaticCondition::WasStartingPlayer { .. }
| StaticCondition::SpellCastWithVariantThisTurn { .. }
| StaticCondition::AnyPlayerAttackedYouLastTurn
| StaticCondition::OpponentPoisonAtLeast { .. }
| StaticCondition::UnlessPay { .. }
| StaticCondition::Unrecognized { .. }
Expand Down
69 changes: 69 additions & 0 deletions crates/engine/src/game/turns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2143,6 +2143,23 @@ fn clear_cleanup_damage(state: &mut GameState, events: &mut Vec<GameEvent>) {
/// choose which cards to discard down to maximum hand size, or `None` if
/// cleanup completes immediately.
pub fn execute_cleanup(state: &mut GameState, events: &mut Vec<GameEvent>) -> Option<WaitingFor> {
// CR 508.6 + CR 514.2: Snapshot this turn's attacks so "attacked you during
// their last turn" (Avenge / O-Kagachi / Weathered Sentinels) can query each
// player's most recent completed turn. Overwrite the active (ending) player's
// entry — empty when they attacked no one, so a no-attack turn correctly
// clears their record; other players' entries are untouched (a skipped player
// never reaches cleanup, so it keeps its genuine last-turn record). Runs
// before `start_next_turn` clears `attacked_defenders_this_turn`, and is
// idempotent under a repeated cleanup step (CR 514.3): same ending player,
// same attacks.
let ending = state.active_player;
let this_turn = state
.attacked_defenders_this_turn
.get(&ending)
.cloned()
.unwrap_or_default();
state.attacked_defenders_last_turn.insert(ending, this_turn);

// CR 701.19b: Regeneration shields expire at cleanup.
// CR 615: Prevention effects also expire.
// CR 514.2: Resolution-time replacements with `expiry: EndOfTurn` (e.g.,
Expand Down Expand Up @@ -6573,6 +6590,58 @@ mod tests {
assert_eq!(state.objects[&id].damage_marked, 0);
}

/// CR 508.6 + CR 514.2: cleanup snapshots this turn's attacks into
/// `attacked_defenders_last_turn`, keyed by the ending (active) player and
/// directional, so "attacked you during their last turn" can query it. A
/// no-attack turn overwrites only that player's entry to empty; other players'
/// records persist (the skipped-player retention property).
#[test]
fn execute_cleanup_snapshots_attacked_defenders_last_turn() {
// P1's turn: P1 declared attackers against P0.
let mut state = setup();
state.active_player = PlayerId(1);
state
.attacked_defenders_this_turn
.insert(PlayerId(1), [PlayerId(0)].into_iter().collect());
let mut events = Vec::new();
execute_cleanup(&mut state, &mut events);

assert!(
state.player_attacked_player_last_turn(PlayerId(1), PlayerId(0)),
"P1 attacked P0 during P1's (now-completed) turn"
);
// The record is one-directional: P0 did not attack P1.
assert!(
!state.player_attacked_player_last_turn(PlayerId(0), PlayerId(1)),
"helper is directional (attacker, defender) — the swap must be false"
);

// P0 then takes a real turn and attacks no one: P0's entry is overwritten
// to empty, while P1's genuine last-turn record is untouched.
state.active_player = PlayerId(0);
state.attacked_defenders_this_turn.clear();
let mut events = Vec::new();
execute_cleanup(&mut state, &mut events);
assert!(
!state.player_attacked_player_last_turn(PlayerId(0), PlayerId(1)),
"P0's no-attack turn leaves no last-turn record"
);
assert!(
state.player_attacked_player_last_turn(PlayerId(1), PlayerId(0)),
"P1's last-turn record persists across another player's turn"
);

// A later real P1 turn with no attack overwrites P1's record to empty.
state.active_player = PlayerId(1);
state.attacked_defenders_this_turn.clear();
let mut events = Vec::new();
execute_cleanup(&mut state, &mut events);
assert!(
!state.player_attacked_player_last_turn(PlayerId(1), PlayerId(0)),
"P1's subsequent no-attack turn clears its record to empty"
);
}

#[test]
fn execute_cleanup_preserves_damage_under_damage_not_removed_static() {
use crate::types::card_type::CoreType;
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/parser/oracle_condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,12 @@ fn static_condition_to_restriction_condition(
| StaticCondition::TopOfLibraryMatches { .. }
| StaticCondition::SourceIsPaired
| StaticCondition::AdditionalCostPaid
// CR 508.6: "a player attacked you during their last turn" is a real
// game-state predicate (Avenge's cost reduction), but it is not a
// cast/activation restriction and has no `ParsedCondition` counterpart —
// it is evaluated via `layers::evaluate_condition` on the self-spell cost
// path, so lowering here returns `None`.
| StaticCondition::AnyPlayerAttackedYouLastTurn
| StaticCondition::CastingAsVariant { .. } => None,
}
}
Expand Down
5 changes: 5 additions & 0 deletions crates/engine/src/parser/oracle_effect/conditions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4820,6 +4820,11 @@ pub(crate) fn static_condition_to_ability_condition(
// no `AbilityCondition` counterpart yet. Return `None` rather than
// lowering it to `Not(IsYourTurn)`, which would be wrong in 2HG.
| StaticCondition::DuringOpponentsTurn
// CR 508.6: the existential "a player attacked you during their last turn"
// gate drives a self-spell cost reduction (Avenge), not an
// effect-resolution rider; no `AbilityCondition` equivalent — lowering
// returns `None`.
| StaticCondition::AnyPlayerAttackedYouLastTurn
| StaticCondition::None => None,
}
}
Expand Down
Loading
Loading