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 @@ -1955,6 +1955,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 @@ -6292,6 +6293,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 @@ -3573,6 +3573,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
140 changes: 140 additions & 0 deletions crates/engine/src/game/casting_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3127,6 +3127,146 @@ 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);
Comment on lines +3167 to +3176

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Test the parsed Avenge path before merge.

Lines 3167-3176 construct StaticDefinition::ModifyCost and StaticCondition::AnyPlayerAttackedYouLastTurn directly. The documented coverage audit still reports that parsed Avenge cost reduction is unsupported because lowering swallows the condition. This test passes while the real Oracle card remains unsupported.

Route the parsed condition into the real cost modifier. Add an end-to-end test that parses Avenge Oracle text and prepares the spell cast.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/casting_tests.rs` around lines 3167 - 3176, Replace
the direct StaticDefinition construction in the affected test with an end-to-end
setup that parses the Avenge Oracle text, then prepares a spell cast through the
real parsing and lowering path. Verify the parsed AnyPlayerAttackedYouLastTurn
condition reaches the cost modifier and applies the reduction, ensuring the test
fails if lowering swallows the condition.

Source: Path instructions

}
(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));

// A departed player remains a valid attacker until their skipped next-turn
// boundary expires the record in `start_next_turn`.
crate::game::elimination::eliminate_player(&mut state, PlayerId(2), &mut Vec::new());
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));
}

#[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 @@ -4241,6 +4241,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 @@ -7851,6 +7852,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
12 changes: 12 additions & 0 deletions crates/engine/src/game/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,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 @@ -1254,6 +1255,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 @@ -1378,6 +1380,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 @@ -1609,6 +1612,14 @@ 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 other player 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.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 @@ -3454,6 +3465,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 @@ -835,6 +835,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
Loading
Loading