From 7972b8d793ba5f85611d3a8fbd33cdc2c55b59b1 Mon Sep 17 00:00:00 2001 From: Jacob Woodson Date: Tue, 4 Aug 2026 11:36:20 -0500 Subject: [PATCH 1/6] Partial: Avenge --- crates/engine/src/game/ability_rw.rs | 9 ++ crates/engine/src/game/ability_scan.rs | 7 + crates/engine/src/game/casting_tests.rs | 135 ++++++++++++++++++ crates/engine/src/game/coverage.rs | 4 + crates/engine/src/game/layers.rs | 14 ++ crates/engine/src/game/quantity.rs | 1 + crates/engine/src/game/turns.rs | 69 +++++++++ crates/engine/src/parser/oracle_condition.rs | 6 + .../src/parser/oracle_effect/conditions.rs | 5 + .../engine/src/parser/oracle_nom/condition.rs | 46 ++++++ .../engine/src/parser/oracle_static/tests.rs | 79 ++++++++++ crates/engine/src/parser/oracle_trigger.rs | 7 + crates/engine/src/types/ability.rs | 10 ++ crates/engine/src/types/game_state.rs | 24 ++++ 14 files changed, 416 insertions(+) diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 08f79eeb71..40f9694a44 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -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 { .. } @@ -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 diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 1e2bde8df2..6e103e61bd 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -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, diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 072095d36d..e50e6cceb9 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -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)); +} + #[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 454dc6368e..c28a2416d3 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -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(), @@ -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), diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index a8f2e124c3..7b10ad6e5a 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -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 @@ -1252,6 +1253,7 @@ fn static_condition_characteristic_reads_at( | StaticCondition::CompletedADungeon | StaticCondition::WasStartingPlayer { .. } | StaticCondition::SpellCastWithVariantThisTurn { .. } + | StaticCondition::AnyPlayerAttackedYouLastTurn | StaticCondition::OpponentPoisonAtLeast { .. } | StaticCondition::UnlessPay { .. } | StaticCondition::DuringYourTurn @@ -1375,6 +1377,7 @@ fn entered_object_perturbs_static_condition( | StaticCondition::CompletedADungeon | StaticCondition::WasStartingPlayer { .. } | StaticCondition::SpellCastWithVariantThisTurn { .. } + | StaticCondition::AnyPlayerAttackedYouLastTurn | StaticCondition::OpponentPoisonAtLeast { .. } | StaticCondition::UnlessPay { .. } | StaticCondition::DuringYourTurn @@ -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) + }), // 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). @@ -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 { .. } diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 00515ffc01..a2c1d6513b 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -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 { .. } diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index 695ebbc11d..98828e61ac 100644 --- a/crates/engine/src/game/turns.rs +++ b/crates/engine/src/game/turns.rs @@ -2143,6 +2143,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., @@ -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; diff --git a/crates/engine/src/parser/oracle_condition.rs b/crates/engine/src/parser/oracle_condition.rs index cd99f7cade..a3f9e249af 100644 --- a/crates/engine/src/parser/oracle_condition.rs +++ b/crates/engine/src/parser/oracle_condition.rs @@ -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, } } diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index a8d40deb4c..9b2758176d 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -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, } } diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index 9c88dc5eec..1329abb196 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -6041,6 +6041,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) } @@ -18545,6 +18558,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 26e43febd4..d1a1f05087 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 b8f6a3ecf9..3f1cff63e8 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -4606,6 +4606,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 4ed11bb88e..33eeb360ba 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -7725,6 +7725,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 d3543664b6..58b24d7bda 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -14139,6 +14139,17 @@ declare_game_state! { /// attacked this turn" (Militant Angel). #[serde(default)] 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 (a skipped player never reaches cleanup, so it keeps its genuine + /// last-turn record). 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")] + pub attacked_defenders_last_turn: HashMap>, /// 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 @@ -18296,6 +18307,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, @@ -18557,6 +18578,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: HashMap::new(), creature_attacked_defenders_this_turn: HashMap::new(), combat_phases_started_this_turn: 0, end_steps_started_this_turn: 0, @@ -20135,6 +20157,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: _, @@ -20429,6 +20452,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 From 8386cfb08b17bbb514a4a2faaf8fb0bb332b2ab9 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 23:22:24 -0700 Subject: [PATCH 2/6] fix(PR-6994): retain departed attack history through turn boundary --- crates/engine/src/game/casting_tests.rs | 5 ++ crates/engine/src/game/layers.rs | 14 ++--- crates/engine/src/game/turns.rs | 61 +++++++++++++++++++ crates/engine/src/types/game_state.rs | 8 +-- .../deterministic_game_state_serde.rs | 18 ++++++ 5 files changed, 94 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 8eee1569df..6544ff0e5a 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -3251,6 +3251,11 @@ fn attacked_you_last_turn_condition_is_existential_over_players() { .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 diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 080f117822..308c3b3647 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -1612,15 +1612,13 @@ 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. + // 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.is_eliminated - && p.id != controller - && state.player_attacked_player_last_turn(p.id, controller) + 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 diff --git a/crates/engine/src/game/turns.rs b/crates/engine/src/game/turns.rs index 56a53e0ab3..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 @@ -6644,6 +6679,32 @@ mod tests { ); } + #[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/types/game_state.rs b/crates/engine/src/types/game_state.rs index e656937c05..e58fdf2899 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -14966,10 +14966,10 @@ declare_game_state! { /// 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 (a skipped player never reaches cleanup, so it keeps its genuine - /// last-turn record). The "last turn" analog of `attacked_defenders_this_turn` - /// powering "attacked you during their last turn" - /// (`StaticCondition::AnyPlayerAttackedYouLastTurn`). + /// 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")] pub attacked_defenders_last_turn: HashMap>, /// CR 508.6 + CR 508.1b: For each creature declared as an attacker this diff --git a/crates/engine/tests/integration/deterministic_game_state_serde.rs b/crates/engine/tests/integration/deterministic_game_state_serde.rs index 632c5659b7..04f584a1bd 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 }, @@ -317,6 +318,7 @@ fn expected_manifest() -> BTreeMap { ); for field in [ "attacked_defenders_this_turn", + "attacked_defenders_last_turn", "creature_attacked_defenders_this_turn", ] { add_spec( @@ -1229,6 +1231,10 @@ fn build_populated_state(reverse: bool) -> GameState { .into_iter() .map(|player| (player, inner_order.into_iter().collect())) .collect(); + state.attacked_defenders_last_turn = 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 +1574,16 @@ fn build_all_direct_numeric_maps_state() -> GameState { [PlayerId(0), PlayerId(1)].into_iter().collect(), ), ]); + state.attacked_defenders_last_turn = 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 +1717,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", @@ -2067,6 +2084,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\":")); From d021e59cd1a81a1e851c03d6514ed155a9f8fb12 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 23:29:00 -0700 Subject: [PATCH 3/6] fix(PR-6994): keep attack history off GameState stack --- crates/engine/src/types/game_state.rs | 4 ++-- .../integration/deterministic_game_state_serde.rs | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index e58fdf2899..6be1957187 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -14971,7 +14971,7 @@ declare_game_state! { /// analog of `attacked_defenders_this_turn` powering "attacked you during their /// last turn" (`StaticCondition::AnyPlayerAttackedYouLastTurn`). #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub attacked_defenders_last_turn: HashMap>, + 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 @@ -19455,7 +19455,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: 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, diff --git a/crates/engine/tests/integration/deterministic_game_state_serde.rs b/crates/engine/tests/integration/deterministic_game_state_serde.rs index 04f584a1bd..e1ec308444 100644 --- a/crates/engine/tests/integration/deterministic_game_state_serde.rs +++ b/crates/engine/tests/integration/deterministic_game_state_serde.rs @@ -1231,10 +1231,12 @@ fn build_populated_state(reverse: bool) -> GameState { .into_iter() .map(|player| (player, inner_order.into_iter().collect())) .collect(); - state.attacked_defenders_last_turn = outer_order - .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), @@ -1574,7 +1576,7 @@ fn build_all_direct_numeric_maps_state() -> GameState { [PlayerId(0), PlayerId(1)].into_iter().collect(), ), ]); - state.attacked_defenders_last_turn = HashMap::from([ + state.attacked_defenders_last_turn = Box::new(HashMap::from([ ( PlayerId(0), [PlayerId(1), PlayerId(0)].into_iter().collect(), @@ -1583,7 +1585,7 @@ fn build_all_direct_numeric_maps_state() -> GameState { PlayerId(1), [PlayerId(0), PlayerId(1)].into_iter().collect(), ), - ]); + ])); state.creature_attacked_defenders_this_turn = HashMap::from([ ( ObjectId(1), From bf5e374ecc9c9e6ee62f47882e9b1172f1933aca Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 00:02:19 -0700 Subject: [PATCH 4/6] test(PR-6994): cover boxed attack history state --- .../fixtures/cr733/authority_matrix.json.gz | Bin 41515 -> 41666 bytes .../deterministic_game_state_serde.rs | 12 ++++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index a89934db679b8d81bde77836fef697273093a946..39b14bf14d6f088b5671212caaee27926527048e 100644 GIT binary patch delta 13560 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 yMI)@L(AO5NdM( delta 13408 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 BTreeMap { ); for field in [ "attacked_defenders_this_turn", - "attacked_defenders_last_turn", "creature_attacked_defenders_this_turn", ] { add_spec( @@ -331,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, @@ -1736,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 { From 67d51ffe3290d38b4e008e0c33b380a35f7df160 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 00:54:40 -0700 Subject: [PATCH 5/6] fix(PR-6994): canonicalize attack history serialization --- crates/engine/src/types/game_state.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 6be1957187..be5715c5bb 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -14971,6 +14971,7 @@ declare_game_state! { /// 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 From 3198d9a67ad990737e3b0185cc9a1d9de46e4d01 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 01:25:15 -0700 Subject: [PATCH 6/6] test(PR-6994): update serde adapter census --- .../engine/tests/integration/deterministic_game_state_serde.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/integration/deterministic_game_state_serde.rs b/crates/engine/tests/integration/deterministic_game_state_serde.rs index 5f5b097528..eed8182619 100644 --- a/crates/engine/tests/integration/deterministic_game_state_serde.rs +++ b/crates/engine/tests/integration/deterministic_game_state_serde.rs @@ -1150,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 [