diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 01fcf6e73f..19e8bb4b74 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -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 { .. } @@ -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 diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 34d475a890..5375509daa 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -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, diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 1b62f45935..6544ff0e5a 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -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); + } + (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; diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index bc47ef2cca..4045c31324 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -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(), @@ -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), diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 265f6d0195..308c3b3647 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -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 @@ -1254,6 +1255,7 @@ fn static_condition_characteristic_reads_at( | StaticCondition::CompletedADungeon | StaticCondition::WasStartingPlayer { .. } | StaticCondition::SpellCastWithVariantThisTurn { .. } + | StaticCondition::AnyPlayerAttackedYouLastTurn | StaticCondition::OpponentPoisonAtLeast { .. } | StaticCondition::UnlessPay { .. } | StaticCondition::DuringYourTurn @@ -1378,6 +1380,7 @@ fn entered_object_perturbs_static_condition( | StaticCondition::CompletedADungeon | StaticCondition::WasStartingPlayer { .. } | StaticCondition::SpellCastWithVariantThisTurn { .. } + | StaticCondition::AnyPlayerAttackedYouLastTurn | StaticCondition::OpponentPoisonAtLeast { .. } | StaticCondition::UnlessPay { .. } | StaticCondition::DuringYourTurn @@ -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) + }), // 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). @@ -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 { .. } diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index ff8a40304a..16c269dea8 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -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 { .. } diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index 27637fc21b..24007e68a1 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -907,6 +907,40 @@ pub(crate) fn select_next_turn_after_completion( } } +/// CR 800.4i: Expires a departed player's last-turn attack record when the turn +/// that player would have taken is skipped in seat order. +fn expire_departed_last_turn_attack_records( + state: &mut GameState, + completed_player: PlayerId, + next_active: PlayerId, + is_extra_turn: bool, +) { + if is_extra_turn || state.seat_order.is_empty() { + return; + } + + let seat_order = &state.seat_order; + let current_idx = seat_order + .iter() + .position(|&player| player == completed_player) + .unwrap_or(0); + for offset in 1..=seat_order.len() { + let idx = super::players::turn_order_index( + current_idx, + offset, + seat_order.len(), + state.turn_direction, + ); + let candidate = seat_order[idx]; + if !super::players::is_alive(state, candidate) { + state.attacked_defenders_last_turn.remove(&candidate); + } + if candidate == next_active { + break; + } + } +} + /// CR 101.4 + CR 103.1 + CR 500.1 + CR 500.7 + CR 805.4: Display-only turn /// projection. Slot 0 is the current live turn representative; later slots are /// the next turns that would actually begin after extra turns, skipped turns, @@ -1067,6 +1101,7 @@ pub fn start_next_turn(state: &mut GameState, events: &mut Vec) { // replacement pipeline so condition-gated skip effects (e.g., Stranglehold) // can observe it. let (next_active, is_extra_turn) = select_next_turn_after_completion(state, completed_player); + expire_departed_last_turn_attack_records(state, completed_player, next_active, is_extra_turn); state.active_player = next_active; // CR 614.10: Simple turn-skip counter (effect-based, e.g., Meditate, Eater of @@ -2156,6 +2191,23 @@ fn clear_cleanup_damage(state: &mut GameState, events: &mut Vec) { /// 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) -> Option { + // 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., @@ -6575,6 +6627,84 @@ 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 start_next_turn_expires_departed_players_last_turn_attack_record() { + use crate::game::elimination::eliminate_player; + use crate::types::format::FormatConfig; + + let mut state = GameState::new(FormatConfig::free_for_all(), 3, 42); + state.active_player = PlayerId(0); + state + .attacked_defenders_last_turn + .insert(PlayerId(1), [PlayerId(0)].into_iter().collect()); + eliminate_player(&mut state, PlayerId(1), &mut Vec::new()); + + assert!( + state.player_attacked_player_last_turn(PlayerId(1), PlayerId(0)), + "the departed player's record persists before their skipped turn boundary" + ); + + start_next_turn(&mut state, &mut Vec::new()); + + assert_eq!(state.active_player, PlayerId(2)); + assert!( + !state.player_attacked_player_last_turn(PlayerId(1), PlayerId(0)), + "the departed player's record expires when their skipped turn boundary is crossed" + ); + } + #[test] fn execute_cleanup_preserves_damage_under_damage_not_removed_static() { use crate::types::card_type::CoreType; diff --git a/crates/engine/src/parser/oracle_condition.rs b/crates/engine/src/parser/oracle_condition.rs index f2c8ca39a0..be63709410 100644 --- a/crates/engine/src/parser/oracle_condition.rs +++ b/crates/engine/src/parser/oracle_condition.rs @@ -407,6 +407,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, } } diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index cf45a7aaee..d96c55f005 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -4822,6 +4822,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, } } diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index fcd46ebc9a..0abeb2ad74 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -6046,6 +6046,19 @@ fn parse_combat_history_condition(input: &str) -> OracleResult<'_, StaticConditi )), ), parse_you_attacked_with_quantity, + // CR 508.6 + CR 109.5: "a player attacked you during their last turn" — + // the existential revenge gate (Avenge's self-spell cost reduction). The + // defender is the controller ("you", CR 109.5); the attacker is + // existential ("a player" / "an opponent"). Distinct reference frame from + // the "you attacked this turn" arms above (attacker-timeline "last turn", + // not current-turn), so it is its own typed condition. + value( + StaticCondition::AnyPlayerAttackedYouLastTurn, + ( + alt((tag("a player"), tag("an opponent"))), + tag(" attacked you during their last turn"), + ), + ), )) .parse(input) } @@ -18640,6 +18653,39 @@ mod tests { ); } + /// CR 508.6 + CR 109.5: "a player / an opponent attacked you during their + /// last turn" lowers to the existential revenge gate (Avenge's cost + /// reduction). Both surfaces reach the same nullary condition, the pre-existing + /// "you attacked this turn" arm in the same combinator is NOT shadowed, and a + /// similar-but-different phrase is not spuriously matched. + #[test] + fn parse_inner_condition_a_player_attacked_you_last_turn() { + for text in [ + "a player attacked you during their last turn", + "an opponent attacked you during their last turn", + ] { + let (rest, c) = parse_inner_condition(text).unwrap(); + assert_eq!(rest, "", "must fully consume {text:?}"); + assert_eq!(c, StaticCondition::AnyPlayerAttackedYouLastTurn, "{text}"); + } + + // Sibling non-shadow: the pre-existing "you attacked this turn" arm in the + // same `parse_combat_history_condition` combinator still lowers to the + // AttackedThisTurn count gate, never the new revenge gate. + let (_, you) = parse_inner_condition("you attacked this turn").unwrap(); + assert_ne!(you, StaticCondition::AnyPlayerAttackedYouLastTurn); + + // Negative: the "this turn" (wrong window) sibling is not matched as the + // "last turn" gate — no false positive on a near-miss phrase. + assert!( + !matches!( + parse_inner_condition("a player attacked you this turn"), + Ok((_, StaticCondition::AnyPlayerAttackedYouLastTurn)) + ), + "the this-turn near-miss must not lower to the last-turn revenge gate" + ); + } + /// CR 608.2c + CR 702.185c: Plasma Bolt's Void clause — a two-sided /// disjunction " or a spell was warped this turn" parses to /// `StaticCondition::Or` over the existing left-half condition and the diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 73f24557b6..b302862585 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -1367,6 +1367,85 @@ fn cant_attack_or_block_gated_on_trailing_as_long_as() { } } +/// CR 508.6 + CR 109.5: Avenge — "This spell costs {2} less to cast if a player +/// attacked you during their last turn." The cost-reduction condition must attach +/// to the `ModifyCost` static (so the {2} reduction is GATED), not be dropped as a +/// `SwallowedClause`/`Condition_If`. Regression for the misparse where +/// `ModifyCost.condition` was `null` and the reduction applied unconditionally. +#[test] +fn modify_cost_gated_on_attacked_you_during_their_last_turn() { + let avenge = crate::parser::oracle::parse_oracle_text( + "This spell costs {2} less to cast if a player attacked you during their last turn.\n\ + Destroy all creatures. You gain 1 life for each creature destroyed this way.", + "Avenge", + &[], + &["Sorcery".to_string()], + &[], + ); + let def = avenge + .statics + .iter() + .find(|d| matches!(d.mode, StaticMode::ModifyCost { .. })) + .expect("expected a ModifyCost static"); + assert_eq!( + def.condition, + Some(StaticCondition::AnyPlayerAttackedYouLastTurn), + "the 'if a player attacked you during their last turn' gate must attach to \ + ModifyCost so the reduction is conditional, got {:?}", + def.condition + ); + assert_eq!( + def.affected, + Some(TargetFilter::SelfRef), + "the reduction applies to this spell (self-referential)" + ); + assert!( + avenge.parse_warnings.is_empty(), + "the cost-reduction condition must not be swallowed; warnings = {:?}", + avenge.parse_warnings + ); + + // The "an opponent" surface reaches the same existential gate, and is likewise + // not swallowed. + let opp = crate::parser::oracle::parse_oracle_text( + "This spell costs {2} less to cast if an opponent attacked you during their last turn.", + "OpponentRevenge", + &[], + &["Sorcery".to_string()], + &[], + ); + assert!( + opp.statics + .iter() + .any(|d| matches!(d.mode, StaticMode::ModifyCost { .. }) + && d.condition == Some(StaticCondition::AnyPlayerAttackedYouLastTurn)), + "the 'an opponent' phrasing must reach the same gate, got {:?}", + opp.statics + ); + assert!( + opp.parse_warnings.is_empty(), + "warnings = {:?}", + opp.parse_warnings + ); + + // No false positive: an unconditional cost reducer carries no condition. + let plain = crate::parser::oracle::parse_oracle_text( + "This spell costs {2} less to cast.", + "PlainReducer", + &[], + &["Sorcery".to_string()], + &[], + ); + assert!( + plain + .statics + .iter() + .any(|d| matches!(d.mode, StaticMode::ModifyCost { .. }) && d.condition.is_none()), + "an unconditional reducer must not spuriously gain the revenge gate, got {:?}", + plain.statics + ); +} + /// CR 611.3a vs duration seam: "for as long as" is effect-duration text /// (`Duration::ForAsLongAs`), NOT a trailing static-restriction gate. The /// combat-restriction "as long as" peel must reject it so Promise of Loyalty diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 781f9d1953..520c6c4907 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -4651,6 +4651,13 @@ pub(crate) fn static_condition_to_trigger_condition( // predicate; no intervening-if (`TriggerCondition`) equivalent — lowering // returns `None`. | StaticCondition::TopOfLibraryMatches { .. } + // CR 508.6: the existential "a player attacked you during their last turn" + // gate is a self-spell cost-reduction / continuous-static predicate + // (Avenge). No card uses this existential form as an intervening-if today + // (O-Kagachi's source-referential "that player" form is a distinct + // variant), so there is no `TriggerCondition` equivalent — lowering + // returns `None`. + | StaticCondition::AnyPlayerAttackedYouLastTurn | StaticCondition::None => None, // CR 309.7: Dungeon completion bridges directly. diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 8462b068df..78ffd54b65 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -7806,6 +7806,16 @@ pub enum StaticCondition { SpellCastWithVariantThisTurn { variant: crate::types::game_state::CastingVariant, }, + /// CR 508.6 + CR 514.2 + CR 109.5: True when any (non-eliminated) player + /// declared a creature attacking the ability's controller ("you") during + /// that player's most recent COMPLETED turn. Existential over players; the + /// defender is the source controller (CR 109.5). Backed by the + /// `attacked_defenders_last_turn` snapshot taken at each turn's cleanup step + /// (CR 514.2). Shared "attacked you during their last turn" revenge + /// predicate: Avenge (this self-spell cost reduction), with O-Kagachi and + /// Weathered Sentinels as future adopters via + /// `GameState::player_attacked_player_last_turn`. + AnyPlayerAttackedYouLastTurn, /// CR 701.27: True when any opponent has at least this many poison counters. OpponentPoisonAtLeast { count: u32, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index a3b7bd50a8..841da2f288 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -14986,6 +14986,18 @@ declare_game_state! { #[serde(default)] #[serde(serialize_with = "crate::types::deterministic_serde::hash_map_of_hash_set")] pub attacked_defenders_this_turn: HashMap>, + /// CR 508.6 + CR 514.2: For each player, the defending players they declared + /// attackers against during that player's MOST RECENT completed turn. + /// Snapshotted from `attacked_defenders_this_turn` at cleanup + /// (`execute_cleanup`), keyed by the ending active player, overwriting so a + /// no-attack turn clears that player's entry while every other player's entry + /// persists. CR 800.4i: A departed player's entry survives until their skipped + /// next-turn boundary, where `start_next_turn` expires it. The "last turn" + /// analog of `attacked_defenders_this_turn` powering "attacked you during their + /// last turn" (`StaticCondition::AnyPlayerAttackedYouLastTurn`). + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + #[serde(serialize_with = "crate::types::deterministic_serde::hash_map_of_hash_set")] + pub attacked_defenders_last_turn: Box>>, /// CR 508.6 + CR 508.1b: For each creature declared as an attacker this /// turn, the defending players it attacked. This is the source-specific /// counterpart to `attacked_defenders_this_turn` for text like "each player @@ -19228,6 +19240,16 @@ impl GameState { .is_some_and(|defenders| defenders.contains(&defender)) } + /// CR 508.6: True if `attacker` declared one or more creatures attacking + /// `defender` during `attacker`'s most recent completed turn. Reads the + /// cleanup-time snapshot (`attacked_defenders_last_turn`); the "last turn" + /// analog of `has_attacked`. + pub fn player_attacked_player_last_turn(&self, attacker: PlayerId, defender: PlayerId) -> bool { + self.attacked_defenders_last_turn + .get(&attacker) + .is_some_and(|defenders| defenders.contains(&defender)) + } + /// CR 508.6: True if `attacker` was declared attacking `defender` this turn. pub fn creature_attacked_player_this_turn( &self, @@ -19489,6 +19511,7 @@ impl GameState { players_attacked_this_turn: HashSet::new(), attacking_creatures_this_turn: HashMap::new(), attacked_defenders_this_turn: HashMap::new(), + attacked_defenders_last_turn: Box::default(), creature_attacked_defenders_this_turn: HashMap::new(), combat_phases_started_this_turn: 0, end_steps_started_this_turn: 0, @@ -21192,6 +21215,7 @@ fn _gamestate_partition_is_total(s: &GameState) { players_attacked_this_turn: _, attacking_creatures_this_turn: _, attacked_defenders_this_turn: _, + attacked_defenders_last_turn: _, creature_attacked_defenders_this_turn: _, combat_phases_started_this_turn: _, end_steps_started_this_turn: _, @@ -21488,6 +21512,7 @@ impl PartialEq for GameState { && self.players_attacked_this_turn == other.players_attacked_this_turn && self.attacking_creatures_this_turn == other.attacking_creatures_this_turn && self.attacked_defenders_this_turn == other.attacked_defenders_this_turn + && self.attacked_defenders_last_turn == other.attacked_defenders_last_turn && self.creature_attacked_defenders_this_turn == other.creature_attacked_defenders_this_turn && self.combat_phases_started_this_turn == other.combat_phases_started_this_turn diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index a89934db67..39b14bf14d 100644 Binary files a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz and b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz differ diff --git a/crates/engine/tests/integration/deterministic_game_state_serde.rs b/crates/engine/tests/integration/deterministic_game_state_serde.rs index 632c5659b7..eed8182619 100644 --- a/crates/engine/tests/integration/deterministic_game_state_serde.rs +++ b/crates/engine/tests/integration/deterministic_game_state_serde.rs @@ -110,6 +110,7 @@ const NUMERIC_MAP_ROUND_TRIP_OWNERS: &[NumericRoundTripOwner] = &[ NumericRoundTripOwner { id: "src/types/game_state.rs::GameState::lands_played_this_turn_by_player", map_key_types: &["PlayerId"], group: RoundTripGroup::DirectGameState, numeric_deserializer: None }, NumericRoundTripOwner { id: "src/types/game_state.rs::GameState::attacking_creatures_this_turn", map_key_types: &["PlayerId"], group: RoundTripGroup::DirectGameState, numeric_deserializer: None }, NumericRoundTripOwner { id: "src/types/game_state.rs::GameState::attacked_defenders_this_turn", map_key_types: &["PlayerId"], group: RoundTripGroup::DirectGameState, numeric_deserializer: None }, + NumericRoundTripOwner { id: "src/types/game_state.rs::GameState::attacked_defenders_last_turn", map_key_types: &["PlayerId"], group: RoundTripGroup::DirectGameState, numeric_deserializer: None }, NumericRoundTripOwner { id: "src/types/game_state.rs::GameState::creature_attacked_defenders_this_turn", map_key_types: &["ObjectId"], group: RoundTripGroup::DirectGameState, numeric_deserializer: None }, NumericRoundTripOwner { id: "src/types/game_state.rs::GameState::cards_discarded_this_turn_by_player", map_key_types: &["PlayerId"], group: RoundTripGroup::DirectGameState, numeric_deserializer: None }, NumericRoundTripOwner { id: "src/types/game_state.rs::GameState::mana_spent_on_spells_this_turn", map_key_types: &["PlayerId"], group: RoundTripGroup::DirectGameState, numeric_deserializer: None }, @@ -329,6 +330,15 @@ fn expected_manifest() -> BTreeMap { Classification::Canonical(HASH_MAP_OF_HASH_SET), ); } + add_spec( + &mut specs, + game_state, + "GameState", + None, + "attacked_defenders_last_turn", + "Box>", + Classification::Canonical(HASH_MAP_OF_HASH_SET), + ); for field in ["objects", "attribution", "lki_cache"] { add_spec( &mut specs, @@ -1140,7 +1150,7 @@ fn serde_hash_owner_census_is_exhaustive_and_every_canonical_owner_names_its_ada assert_eq!( NUMERIC_MAP_ROUND_TRIP_OWNERS.len(), - 50, + 51, "the reviewed numeric-map owner matrix must remain exact" ); for group in [ @@ -1229,6 +1239,12 @@ fn build_populated_state(reverse: bool) -> GameState { .into_iter() .map(|player| (player, inner_order.into_iter().collect())) .collect(); + state.attacked_defenders_last_turn = Box::new( + outer_order + .into_iter() + .map(|player| (player, inner_order.into_iter().collect())) + .collect(), + ); state.steps_to_skip = vec![ [ (engine::types::Phase::PostCombatMain, 2), @@ -1568,6 +1584,16 @@ fn build_all_direct_numeric_maps_state() -> GameState { [PlayerId(0), PlayerId(1)].into_iter().collect(), ), ]); + state.attacked_defenders_last_turn = Box::new(HashMap::from([ + ( + PlayerId(0), + [PlayerId(1), PlayerId(0)].into_iter().collect(), + ), + ( + PlayerId(1), + [PlayerId(0), PlayerId(1)].into_iter().collect(), + ), + ])); state.creature_attacked_defenders_this_turn = HashMap::from([ ( ObjectId(1), @@ -1701,6 +1727,7 @@ fn every_direct_numeric_key_game_state_map_round_trips_populated() { "lands_played_this_turn_by_player", "attacking_creatures_this_turn", "attacked_defenders_this_turn", + "attacked_defenders_last_turn", "creature_attacked_defenders_this_turn", "cards_discarded_this_turn_by_player", "mana_spent_on_spells_this_turn", @@ -1717,7 +1744,7 @@ fn every_direct_numeric_key_game_state_map_round_trips_populated() { ]; assert_eq!( direct_fields.len(), - 39, + 40, "private stack_trigger_firings is covered by its unit test" ); for field in direct_fields { @@ -2067,6 +2094,7 @@ fn real_game_state_hash_owners_are_canonical_and_round_trip_across_all_persisten ); assert!(forward_json.contains("\"ring_level\":{\"0\":1,\"1\":2}")); assert!(forward_json.contains("\"attacked_defenders_this_turn\":{\"0\":[0,1],\"1\":[0,1]}")); + assert!(forward_json.contains("\"attacked_defenders_last_turn\":{\"0\":[0,1],\"1\":[0,1]}")); assert!(forward_json .contains("\"steps_to_skip\":[{\"PreCombatMain\":1,\"PostCombatMain\":2},{\"End\":3}]")); assert!(forward_json.contains("\"objects\":{\"1\":"));