diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 53e3af4358..79ca334d88 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -31224,6 +31224,23 @@ fn public_attack_prohibition_parser_preserves_legacy_anaphora_and_connector() { } )); assert!(connector.duration.is_none()); + + let planeswalker_only = parse_effect_chain( + "that player can't attack planeswalkers you control during their next turn.", + AbilityKind::Spell, + ); + assert!(matches!( + planeswalker_only.effect.as_ref(), + Effect::AddRestriction { + restriction: GameRestriction::ProhibitActivity { + activity: ProhibitedActivity::Attack { + defended: crate::types::triggers::AttackTargetFilter::Planeswalker, + protected_player: None, + }, + .. + }, + } + )); } #[test] @@ -31233,6 +31250,10 @@ fn scoped_cant_attack_prohibition_supports_both_verbs_and_all_defended_scopes() for verb in ["can't", "cannot"] { for (scope, expected_defended) in [ ("you", AttackTargetFilter::Player), + ( + "planeswalkers you control", + AttackTargetFilter::Planeswalker, + ), ( "you or planeswalkers you control", AttackTargetFilter::PlayerOrPlaneswalker, diff --git a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs index d3e5d2c6bc..7336bb0184 100644 --- a/crates/engine/src/parser/oracle_ir/snapshot_tests.rs +++ b/crates/engine/src/parser/oracle_ir/snapshot_tests.rs @@ -2487,6 +2487,22 @@ fn liliana_the_repentant() { insta::assert_json_snapshot!("liliana_the_repentant_lowered", &lowered); } +/// CR 508.1b-c + CR 508.1h + CR 602.2: Onakke's two printed lines exercise both the +/// planeswalker-only combat-tax static and its graveyard activation. Snapshot +/// both document IR and lowering so neither line can silently degrade while +/// the other stays supported. +#[test] +fn onakke_oathkeeper() { + let (ir, lowered) = parse_two_layer( + "Creatures can't attack planeswalkers you control unless their controller pays {1} for each creature they control that's attacking a planeswalker you control.\n{4}{W}{W}, Exile this card from your graveyard: Return target planeswalker card from your graveyard to the battlefield.", + "Onakke Oathkeeper", + &["Creature"], + &["Ogre", "Spirit"], + ); + insta::assert_json_snapshot!("onakke_oathkeeper_ir", &ir); + insta::assert_json_snapshot!("onakke_oathkeeper_lowered", &lowered); +} + /// CR 702.142a Boast: pins the order of the two IMPLICIT restrictions. /// /// No Boast card in the pool states its activation instruction outside reminder diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__onakke_oathkeeper_ir.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__onakke_oathkeeper_ir.snap new file mode 100644 index 0000000000..8a355ae3fa --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__onakke_oathkeeper_ir.snap @@ -0,0 +1,200 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&ir" +--- +{ + "items": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 0 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 157, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "Creatures can't attack planeswalkers you control unless their controller pays {1} for each creature they control that's attacking a planeswalker you control." + }, + "node": { + "Static": { + "definition": { + "mode": "CantAttack", + "affected": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "Opponent", + "properties": [] + }, + "modifications": [], + "condition": { + "type": "UnlessPay", + "cost": { + "type": "Cost", + "shards": [], + "generic": 1 + }, + "scaling": { + "type": "PerAffectedCreature" + }, + "defended": "Planeswalker" + }, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "Creatures can't attack planeswalkers you control unless their controller pays {1} for each creature they control that's attacking a planeswalker you control." + }, + "source_text": "Creatures can't attack planeswalkers you control unless their controller pays {1} for each creature they control that's attacking a planeswalker you control.", + "body_ir": null + } + } + }, + { + "id": 1, + "source": { + "id": { + "item": 1, + "ordinal": 0 + }, + "span": { + "first_line": 1, + "last_line": 1, + "start_byte": 158, + "end_byte": 277, + "precision": "Exact", + "ordinal_within_span": 0 + }, + "fragment": "{4}{W}{W}, Exile this card from your graveyard: Return target planeswalker card from your graveyard to the battlefield." + }, + "node": { + "Spell": { + "source_text": "Return target planeswalker card from your graveyard to the battlefield", + "body": { + "clauses": [ + { + "id": 0, + "source": { + "id": { + "item": 0, + "ordinal": 1 + }, + "span": { + "first_line": 0, + "last_line": 0, + "start_byte": 0, + "end_byte": 70, + "precision": "ChainRelative", + "ordinal_within_span": 0 + }, + "fragment": "Return target planeswalker card from your graveyard to the battlefield" + }, + "disposition": { + "Emit": { + "followup": null, + "intrinsic": null + } + }, + "parsed": { + "effect": { + "type": "ChangeZone", + "origin": "Graveyard", + "destination": "Battlefield", + "target": { + "type": "Typed", + "type_filters": [ + "Planeswalker" + ], + "controller": "You", + "properties": [ + { + "type": "InZone", + "zone": "Graveyard" + } + ] + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "duration": null, + "sub_ability": null, + "distribute": null, + "multi_target": null, + "condition": null, + "optional": false, + "unless_pay": null + }, + "boundary": null, + "condition": null, + "is_optional": false, + "opponent_may_scope": null, + "repeat_for": null, + "player_scope": null, + "starting_with": null, + "delayed_condition": null, + "prefix_delayed_condition": null, + "multi_target": null, + "where_x_expression": null, + "unless_pay": null + } + ], + "kind": "Activated", + "continuation_kind": null, + "player_scope_rewrite": "Apply", + "chain_rounding": null, + "actor": null, + "in_trigger": false, + "repeat_until": null + }, + "shell": { + "sub_link": null, + "cost": { + "type": "Composite", + "costs": [ + { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "White", + "White" + ], + "generic": 4 + } + }, + { + "type": "Exile", + "count": 1, + "zone": "Graveyard", + "filter": { + "type": "SelfRef" + } + } + ] + }, + "activation_zone": "Graveyard", + "description": "{4}{W}{W}, Exile this card from your graveyard: Return target planeswalker card from your graveyard to the battlefield.", + "stages": [ + "NormalizeActivatedManaInstead", + "ExtractCostReduction", + "ExtractManaSpendTrigger" + ] + }, + "die_results": [], + "root_transforms": [] + } + } + } + ], + "source_text": "Creatures can't attack planeswalkers you control unless their controller pays {1} for each creature they control that's attacking a planeswalker you control.\n{4}{W}{W}, Exile this card from your graveyard: Return target planeswalker card from your graveyard to the battlefield.", + "card_name": "Onakke Oathkeeper" +} diff --git a/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__onakke_oathkeeper_lowered.snap b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__onakke_oathkeeper_lowered.snap new file mode 100644 index 0000000000..a726d5183e --- /dev/null +++ b/crates/engine/src/parser/oracle_ir/snapshots/engine__parser__oracle_ir__snapshot_tests__onakke_oathkeeper_lowered.snap @@ -0,0 +1,100 @@ +--- +source: crates/engine/src/parser/oracle_ir/snapshot_tests.rs +expression: "&lowered" +--- +{ + "abilities": [ + { + "kind": "Activated", + "effect": { + "type": "ChangeZone", + "origin": "Graveyard", + "destination": "Battlefield", + "target": { + "type": "Typed", + "type_filters": [ + "Planeswalker" + ], + "controller": "You", + "properties": [ + { + "type": "InZone", + "zone": "Graveyard" + } + ] + }, + "owner_library": false, + "enter_transformed": false, + "enter_tapped": false, + "enters_attacking": false + }, + "cost": { + "type": "Composite", + "costs": [ + { + "type": "Mana", + "cost": { + "type": "Cost", + "shards": [ + "White", + "White" + ], + "generic": 4 + } + }, + { + "type": "Exile", + "count": 1, + "zone": "Graveyard", + "filter": { + "type": "SelfRef" + } + } + ] + }, + "sub_ability": null, + "duration": null, + "description": "{4}{W}{W}, Exile this card from your graveyard: Return target planeswalker card from your graveyard to the battlefield.", + "target_prompt": null, + "activation_zone": "Graveyard", + "condition": null, + "optional_targeting": false, + "optional": false, + "forward_result": false + } + ], + "triggers": [], + "statics": [ + { + "mode": "CantAttack", + "affected": { + "type": "Typed", + "type_filters": [ + "Creature" + ], + "controller": "Opponent", + "properties": [] + }, + "modifications": [], + "condition": { + "type": "UnlessPay", + "cost": { + "type": "Cost", + "shards": [], + "generic": 1 + }, + "scaling": { + "type": "PerAffectedCreature" + }, + "defended": "Planeswalker" + }, + "affected_zone": null, + "effect_zone": null, + "active_zones": [], + "characteristic_defining": false, + "description": "Creatures can't attack planeswalkers you control unless their controller pays {1} for each creature they control that's attacking a planeswalker you control." + } + ], + "replacements": [], + "extractedKeywords": [] +} diff --git a/crates/engine/src/parser/oracle_static/evasion.rs b/crates/engine/src/parser/oracle_static/evasion.rs index 7853d23fda..e17effb34c 100644 --- a/crates/engine/src/parser/oracle_static/evasion.rs +++ b/crates/engine/src/parser/oracle_static/evasion.rs @@ -2396,9 +2396,10 @@ pub(crate) fn try_parse_ignore_landwalk_for_blocking( ) } -/// CR 508.1d + CR 508.1h + CR 509.1c + CR 118.12a: Parse the combat-tax static family: +/// CR 508.1b-c + CR 508.1h + CR 509.1c + CR 118.12a: Parse the combat-tax static family: /// -/// - "Creatures can't attack [you | you or planeswalkers you control] unless their +/// - "Creatures can't attack [you | planeswalkers you control | you or planeswalkers +/// you control] unless their /// controller pays {N} [for each of those creatures][, where X is the number of /// ][.]" /// - "Creatures can't block unless their controller pays {N} [for each of those @@ -2544,7 +2545,7 @@ pub(crate) fn parse_crew_contribution_static(text: &str) -> Option @@ -2639,6 +2640,10 @@ pub(crate) fn parse_combat_tax_body(input: &str) -> OracleResult<'_, CombatTaxPa AttackTargetFilter::PlayerOrPlaneswalker, tag_no_case::<_, _, OracleError<'_>>(" you or planeswalkers you control"), ), + value( + AttackTargetFilter::Planeswalker, + tag_no_case::<_, _, OracleError<'_>>(" planeswalkers you control"), + ), value( AttackTargetFilter::Player, tag_no_case::<_, _, OracleError<'_>>(" you"), @@ -2670,6 +2675,9 @@ pub(crate) fn parse_combat_tax_body(input: &str) -> OracleResult<'_, CombatTaxPa tag_no_case::<_, _, OracleError<'_>>( " for each creature they control that's attacking you or a planeswalker you control", ), + tag_no_case::<_, _, OracleError<'_>>( + " for each creature they control that's attacking a planeswalker you control", + ), tag_no_case::<_, _, OracleError<'_>>( " for each creature they control that's attacking you", ), diff --git a/crates/engine/src/parser/oracle_static/shared.rs b/crates/engine/src/parser/oracle_static/shared.rs index c1344175a0..28657766ec 100644 --- a/crates/engine/src/parser/oracle_static/shared.rs +++ b/crates/engine/src/parser/oracle_static/shared.rs @@ -5834,6 +5834,10 @@ pub(crate) fn parse_cant_attack_defended_scope_nom( AttackTargetFilter::PlayerOrPlaneswalker, tag(" you or planeswalkers you control"), ), + value( + AttackTargetFilter::Planeswalker, + tag(" planeswalkers you control"), + ), value(AttackTargetFilter::Player, tag(" you")), ))) .parse(input) diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index f23c3a24e4..26e43febd4 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -25866,6 +25866,82 @@ fn combat_tax_sphere_of_safety_defended_player_or_planeswalker() { } } +/// CR 506.3 + CR 508.1b-c + CR 508.1h: Onakke Oathkeeper — a planeswalker-only +/// combat tax must preserve both the typed `Planeswalker` scope and the +/// per-attacker scaling from its relative clause. +#[test] +fn combat_tax_onakke_oathkeeper_defended_planeswalker_only() { + let def = parse_static_line( + "Creatures can't attack planeswalkers you control unless their controller pays {1} for each creature they control that's attacking a planeswalker you control.", + ) + .expect("Onakke Oathkeeper should parse"); + assert_eq!(def.mode, StaticMode::CantAttack); + let (cost, scaling) = extract_unless_pay(&def); + assert_eq!(cost.mana_value(), 1); + assert!(matches!(scaling, UnlessPayScaling::PerAffectedCreature)); + let Some(StaticCondition::UnlessPay { defended, .. }) = def.condition.as_ref() else { + panic!( + "expected Onakke Oathkeeper combat-tax payload, got {:?}", + def.condition + ); + }; + assert_eq!( + defended.as_ref(), + Some(&crate::types::triggers::AttackTargetFilter::Planeswalker), + "Onakke Oathkeeper must defend only planeswalkers controlled by its controller", + ); +} + +/// CR 508.1c: all static consumers of the shared defended-scope grammar must +/// retain the planeswalker-only variant. These are deliberately different +/// sentence envelopes, so a regression in a downstream consumer cannot hide +/// behind the combat-tax parser's direct use of the same combinator. +#[test] +fn planeswalker_only_defended_scope_reaches_every_static_consumer() { + let subject = parse_static_line("Creatures can't attack planeswalkers you control.") + .expect("subject combat rule should parse"); + assert_eq!( + subject.attack_defended, + Some(crate::types::triggers::AttackTargetFilter::Planeswalker) + ); + + let split = parse_static_line_multi( + "Enchanted creature gets +1/+1 and can't attack planeswalkers you control.", + ); + assert!(split.iter().any(|def| { + def.mode == StaticMode::CantAttack + && def.attack_defended == Some(crate::types::triggers::AttackTargetFilter::Planeswalker) + })); + + let combined = parse_static_line_multi( + "Creatures can't attack planeswalkers you control or block creatures you control.", + ); + assert!(combined.iter().any(|def| { + def.mode == StaticMode::CantAttack + && def.attack_defended == Some(crate::types::triggers::AttackTargetFilter::Planeswalker) + })); +} + +/// CR 508.1h + CR 118.12a: mixed casing must follow the same nom grammar and +/// retain both the planeswalker-only defender and relative per-attacker tax. +#[test] +fn mixed_case_planeswalker_only_combat_tax_preserves_scaling() { + let def = parse_static_line( + "cReAtUrEs CaN't AtTaCk PlAnEsWaLkErS yOu CoNtRoL uNlEsS tHeIr CoNtRoLlEr PaYs {2} fOr EaCh CrEaTuRe ThEy CoNtRoL tHaT's AtTaCkInG a PlAnEsWaLkEr YoU cOnTrOl.", + ) + .expect("mixed-case combat tax should parse"); + let (cost, scaling) = extract_unless_pay(&def); + assert_eq!(cost.mana_value(), 2); + assert_eq!(scaling, UnlessPayScaling::PerAffectedCreature); + let Some(StaticCondition::UnlessPay { defended, .. }) = def.condition.as_ref() else { + panic!("expected combat-tax condition, got {:?}", def.condition); + }; + assert_eq!( + defended.as_ref(), + Some(&crate::types::triggers::AttackTargetFilter::Planeswalker) + ); +} + /// CR 509.1c: Block-side restriction — `defended` is `None` because the /// "defender" of a block restriction is implicit (the static's controller). #[test] diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 90e0f718d3..4f16b16b57 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -784,6 +784,7 @@ mod old_growth_troll_return_as_aura; mod omniscience_free_cast_chalice_x; mod omniscience_free_cast_vexing_bauble; mod omo_queen_of_vesuva; +mod onakke_oathkeeper; mod oracle_parser; mod orzhov_advokist; mod overload_no_legal_target; diff --git a/crates/engine/tests/integration/onakke_oathkeeper.rs b/crates/engine/tests/integration/onakke_oathkeeper.rs new file mode 100644 index 0000000000..8ff15640e7 --- /dev/null +++ b/crates/engine/tests/integration/onakke_oathkeeper.rs @@ -0,0 +1,164 @@ +//! CR 113.6j: Onakke Oathkeeper's graveyard activation must use its printed +//! activation zone and pay both parts of its composite cost through the normal +//! action pipeline. + +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::game::zones::move_to_zone; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const ONAKKE_OATHKEEPER_ORACLE: &str = "Creatures can't attack planeswalkers you control unless their controller pays {1} for each creature they control that's attacking a planeswalker you control.\n{4}{W}{W}, Exile this card from your graveyard: Return target planeswalker card from your graveyard to the battlefield."; + +fn mana(count: usize, mana_type: ManaType) -> Vec { + (0..count) + .map(|_| ManaUnit::new(mana_type, ObjectId(0), false, vec![])) + .collect() +} + +/// CR 602.2b / CR 115.1c / CR 601.2c-h: the printed graveyard activation +/// announces its target before paying `{4}{W}{W}` and exiling its source, then +/// can return only its controller's planeswalker card from that graveyard. +#[test] +fn onakke_oathkeeper_graveyard_activation_returns_own_planeswalker() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let oathkeeper = scenario + .add_creature_from_oracle(P0, "Onakke Oathkeeper", 2, 2, ONAKKE_OATHKEEPER_ORACLE) + .id(); + let own_planeswalker = scenario + .add_creature(P0, "Own Jace", 0, 0) + .as_planeswalker_with_loyalty("Jace", 4) + .id(); + // A second legal target prevents the activation pipeline from + // auto-selecting the only available planeswalker. + let other_own_planeswalker = scenario + .add_creature(P0, "Other Jace", 0, 0) + .as_planeswalker_with_loyalty("Jace", 4) + .id(); + let opponent_planeswalker = scenario + .add_creature(P1, "Opponent Chandra", 0, 0) + .as_planeswalker_with_loyalty("Chandra", 4) + .id(); + scenario.with_mana_pool( + P0, + mana(4, ManaType::Colorless) + .into_iter() + .chain(mana(2, ManaType::White)) + .collect(), + ); + let mut runner = scenario.build(); + let mut events = Vec::new(); + move_to_zone(runner.state_mut(), oathkeeper, Zone::Graveyard, &mut events); + move_to_zone( + runner.state_mut(), + own_planeswalker, + Zone::Graveyard, + &mut events, + ); + move_to_zone( + runner.state_mut(), + other_own_planeswalker, + Zone::Graveyard, + &mut events, + ); + move_to_zone( + runner.state_mut(), + opponent_planeswalker, + Zone::Graveyard, + &mut events, + ); + + runner + .act(GameAction::ActivateAbility { + source_id: oathkeeper, + ability_index: 0, + }) + .expect("Onakke Oathkeeper activation must enter target selection"); + + // CR 602.2b + CR 601.2c: target legality is fixed before costs are paid. + let WaitingFor::TargetSelection { + target_slots, + selection, + .. + } = runner.state().waiting_for.clone() + else { + panic!( + "expected target selection after activation, got {:?}", + runner.state().waiting_for + ); + }; + let legal_targets = &target_slots[selection.current_slot].legal_targets; + assert!( + legal_targets.contains(&TargetRef::Object(own_planeswalker)), + "the controller's planeswalker card must be a legal target" + ); + assert!( + legal_targets.contains(&TargetRef::Object(other_own_planeswalker)), + "the controller's other planeswalker card must be a legal target" + ); + assert!( + !legal_targets.contains(&TargetRef::Object(opponent_planeswalker)), + "the opponent's planeswalker card must not be a legal target" + ); + + let before_illegal_target = runner.state().clone(); + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(opponent_planeswalker)), + }) + .expect_err("choosing the opponent's planeswalker must be rejected"); + assert_eq!( + runner.state(), + &before_illegal_target, + "an illegal target choice must leave the game state unchanged" + ); + + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(own_planeswalker)), + }) + .expect("choosing the controller's planeswalker must be accepted"); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::Priority { player: P0 } + ), + "the unambiguous mana pool must auto-pay the activation and return priority" + ); + assert_eq!( + runner.state().objects[&oathkeeper].zone, + Zone::Exile, + "the self-exile cost must be paid before the activated ability is put on the stack" + ); + assert_eq!( + runner.state().stack.len(), + 1, + "the fully paid targeted activation must be waiting on the stack" + ); + + // CR 602.2b + CR 601.2h: with an unambiguous pool, the production payment + // pipeline automatically pays the mana leg and self-exile cost, then priority + // resumes for the ability already on the stack. + runner.advance_until_stack_empty(); + + assert_eq!(runner.state().objects[&oathkeeper].zone, Zone::Exile); + assert_eq!( + runner.state().objects[&own_planeswalker].zone, + Zone::Battlefield + ); + assert_eq!( + runner.state().objects[&other_own_planeswalker].zone, + Zone::Graveyard, + "the unchosen controller's planeswalker remains in the graveyard" + ); + assert_eq!( + runner.state().objects[&opponent_planeswalker].zone, + Zone::Graveyard, + "the opponent's planeswalker was never a legal target" + ); +} diff --git a/crates/engine/tests/integration/orzhov_advokist.rs b/crates/engine/tests/integration/orzhov_advokist.rs index b74495b230..01175ea071 100644 --- a/crates/engine/tests/integration/orzhov_advokist.rs +++ b/crates/engine/tests/integration/orzhov_advokist.rs @@ -24,6 +24,7 @@ use engine::types::triggers::AttackTargetFilter; use engine::types::zones::Zone; const ORZHOV_ADVOKIST_ORACLE: &str = "At the beginning of your upkeep, each player may put two +1/+1 counters on a creature they control. If a player does, creatures that player controls can't attack you or planeswalkers you control until your next turn."; +const PLANESWALKER_ONLY_ADVOKIST_ORACLE: &str = "At the beginning of your upkeep, each player may put two +1/+1 counters on a creature they control. If a player does, creatures that player controls can't attack planeswalkers you control until your next turn."; const P0: PlayerId = PlayerId(0); const P1: PlayerId = PlayerId(1); @@ -328,3 +329,91 @@ fn orzhov_advokist_restriction_tracks_acceptance_controller_changes_and_expiry() AttackTarget::Player(P0) )); } + +/// CR 608.2c + CR 611.2c: the scoped AddRestriction route snapshots the +/// accepting player, protected player, and controller-next-turn expiry when it +/// resolves. The planeswalker-only template makes player and unrelated-walker +/// attacks positive sibling cases rather than vacuous negatives. +#[test] +fn scoped_advokist_planeswalker_only_restriction_snapshots_controller_provenance() { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::Upkeep); + let source = scenario + .add_creature_from_oracle( + P0, + "Scoped Advokist Fixture", + 1, + 4, + PLANESWALKER_ONLY_ADVOKIST_ORACLE, + ) + .id(); + let p1_a = scenario.add_creature(P1, "P1 Bear A", 2, 2).id(); + let p1_b = scenario.add_creature(P1, "P1 Bear B", 2, 2).id(); + let p0_walker = scenario + .add_creature(P0, "Protected Jace", 0, 0) + .as_planeswalker_with_loyalty("Jace", 4) + .id(); + let p2_walker = scenario + .add_creature(P2, "Other Chandra", 0, 0) + .as_planeswalker_with_loyalty("Chandra", 4) + .id(); + let mut runner = scenario.build(); + for object in [p1_a, p1_b] { + runner + .state_mut() + .objects + .get_mut(&object) + .unwrap() + .summoning_sick = false; + } + resolve_advokist_upkeep(&mut runner, source, p1_a, p1_b); + + assert!(matches!( + runner.state().restrictions.as_slice(), + [GameRestriction::ProhibitActivity { + source: stored_source, + affected_players: RestrictionPlayerScope::SpecificPlayer(P1), + expiry: RestrictionExpiry::UntilPlayerNextTurn { player: P0 }, + activity: ProhibitedActivity::Attack { + defended: AttackTargetFilter::Planeswalker, + protected_player: Some(P0), + }, + }] if *stored_source == source + )); + assert!(!attack_is_legal( + runner.state(), + P1, + p1_a, + AttackTarget::Planeswalker(p0_walker) + )); + assert!(attack_is_legal( + runner.state(), + P1, + p1_a, + AttackTarget::Player(P0) + )); + assert!(attack_is_legal( + runner.state(), + P1, + p1_a, + AttackTarget::Planeswalker(p2_walker) + )); + + runner + .state_mut() + .objects + .get_mut(&source) + .unwrap() + .controller = P2; + let mut events = Vec::new(); + move_to_zone(runner.state_mut(), source, Zone::Graveyard, &mut events); + assert!( + !attack_is_legal( + runner.state(), + P1, + p1_a, + AttackTarget::Planeswalker(p0_walker) + ), + "changing or removing the source cannot mutate the resolved snapshot" + ); +} diff --git a/crates/engine/tests/integration/rules/combat.rs b/crates/engine/tests/integration/rules/combat.rs index 1a1bd356f2..86cceb82f6 100644 --- a/crates/engine/tests/integration/rules/combat.rs +++ b/crates/engine/tests/integration/rules/combat.rs @@ -817,6 +817,241 @@ fn add_sphere_of_safety(scenario: &mut GameScenario, player: PlayerId) -> Object builder.id() } +/// Build the exact static ability of Onakke Oathkeeper. The test fixture uses a +/// creature shell because its combat-tax behavior, rather than printed P/T, is +/// the behavior under test. +fn add_onakke_oathkeeper(scenario: &mut GameScenario, player: PlayerId) -> ObjectId { + let def = parse_static_line( + "Creatures can't attack planeswalkers you control unless their controller pays {1} for each creature they control that's attacking a planeswalker you control.", + ) + .expect("Onakke Oathkeeper should parse"); + let mut builder = scenario.add_creature(player, "Onakke Oathkeeper", 2, 2); + builder.with_static_definition(def); + builder.id() +} + +/// Park a three-player combat at declaration with two legal planeswalker +/// targets: one controlled by Onakke Oathkeeper's controller (P1), and one +/// controlled by a different defending player (P2). +fn build_3p_onakke_oathkeeper_scenario( +) -> (GameRunner, ObjectId, ObjectId, ObjectId, ObjectId, ObjectId) { + const P2: PlayerId = PlayerId(2); + + let mut scenario = GameScenario::new_n_player(3, 42); + let oathkeeper = add_onakke_oathkeeper(&mut scenario, P1); + let protected = scenario + .add_creature(P1, "Protected Jace", 2, 2) + .as_planeswalker_with_loyalty("Jace", 4) + .id(); + let other = scenario + .add_creature(P2, "Other Chandra", 2, 2) + .as_planeswalker_with_loyalty("Chandra", 4) + .id(); + let attacker = scenario.add_creature(P0, "Bear", 2, 2).id(); + let second_attacker = scenario.add_creature(P0, "Wolf", 2, 2).id(); + let mut runner = scenario.build(); + let state = runner.state_mut(); + state.active_player = P0; + state.priority_player = P0; + state.phase = Phase::DeclareAttackers; + state.turn_number = 2; + state.waiting_for = WaitingFor::DeclareAttackers { + player: P0, + valid_attacker_ids: vec![attacker, second_attacker], + valid_attack_targets: vec![ + AttackTarget::Player(P1), + AttackTarget::Planeswalker(protected), + AttackTarget::Planeswalker(other), + ], + valid_attack_targets_by_attacker: None, + attacker_constraints: Default::default(), + }; + ( + runner, + oathkeeper, + attacker, + second_attacker, + protected, + other, + ) +} + +/// CR 506.3 + CR 508.1b + CR 508.1h: Onakke Oathkeeper taxes an attack at a +/// planeswalker controlled by its controller, but not an attack at that player +/// or a planeswalker controlled by another player. The three assertions drive +/// the real declaration pipeline and fail if either the type or controller +/// component of the parsed `Planeswalker` scope is lost. +#[test] +fn onakke_oathkeeper_taxes_only_planeswalkers_controlled_by_its_controller() { + let (mut protected_runner, _, attacker, _, protected, _) = + build_3p_onakke_oathkeeper_scenario(); + protected_runner + .act(GameAction::DeclareAttackers { + attacks: vec![(attacker, AttackTarget::Planeswalker(protected))], + bands: vec![], + }) + .expect("attack at Onakke controller's planeswalker should enter tax payment"); + match &protected_runner.state().waiting_for { + WaitingFor::CombatTaxPayment { + player, + context, + total_cost, + per_creature, + .. + } => { + assert_eq!(*player, P0); + assert!(matches!(context, CombatTaxContext::Attacking)); + assert_eq!(total_cost.mana_value(), 1); + assert_eq!(per_creature.len(), 1); + } + other => panic!("expected CombatTaxPayment for protected planeswalker, got {other:?}"), + } + + let (mut player_runner, _, attacker, _, _, _) = build_3p_onakke_oathkeeper_scenario(); + player_runner + .act(GameAction::DeclareAttackers { + attacks: vec![(attacker, AttackTarget::Player(P1))], + bands: vec![], + }) + .expect("attack at Onakke controller should be legal without a tax"); + assert!( + !matches!( + player_runner.state().waiting_for, + WaitingFor::CombatTaxPayment { .. } + ), + "Onakke Oathkeeper must not tax attacks at its controller directly", + ); + + let (mut other_runner, _, attacker, _, _, other) = build_3p_onakke_oathkeeper_scenario(); + other_runner + .act(GameAction::DeclareAttackers { + attacks: vec![(attacker, AttackTarget::Planeswalker(other))], + bands: vec![], + }) + .expect("attack at another player's planeswalker should be legal without a tax"); + assert!( + !matches!( + other_runner.state().waiting_for, + WaitingFor::CombatTaxPayment { .. } + ), + "Onakke Oathkeeper must not tax planeswalkers its controller does not control", + ); +} + +/// CR 508.1h: the relative clause counts every creature that controller has +/// attacking the protected planeswalker. Its defended-player provenance is +/// live while the source remains on the battlefield, then disappears when the +/// source leaves. +#[test] +fn onakke_oathkeeper_scales_per_attacker_and_tracks_live_source_controller() { + const P2: PlayerId = PlayerId(2); + let (mut runner, _oathkeeper, attacker, second_attacker, protected, _other) = + build_3p_onakke_oathkeeper_scenario(); + runner + .act(GameAction::DeclareAttackers { + attacks: vec![ + (attacker, AttackTarget::Planeswalker(protected)), + (second_attacker, AttackTarget::Planeswalker(protected)), + ], + bands: vec![], + }) + .expect("two protected-planeswalker attackers should enter tax payment"); + match &runner.state().waiting_for { + WaitingFor::CombatTaxPayment { total_cost, .. } => { + assert_eq!( + total_cost.mana_value(), + 2, + "two attackers scale {{1}} to {{2}}" + ); + } + other => panic!("expected CombatTaxPayment for two attackers, got {other:?}"), + } + + // Start a fresh declaration for the provenance assertions: the first + // declaration has intentionally committed two attackers to its tax window. + let (mut runner, oathkeeper, attacker, _, _, other) = build_3p_onakke_oathkeeper_scenario(); + + // A static source's controller is queried live. Give the Oathkeeper to P2 + // through the production Layer 2 control-change path: P1 is now untaxed + // directly, while P2's planeswalker is protected. + runner.state_mut().add_transient_continuous_effect( + oathkeeper, + P2, + engine::types::ability::Duration::Permanent, + engine::types::ability::TargetFilter::SpecificObject { id: oathkeeper }, + vec![engine::types::ability::ContinuousModification::ChangeController], + None, + ); + engine::game::layers::evaluate_layers(runner.state_mut()); + runner.state_mut().waiting_for = WaitingFor::DeclareAttackers { + player: P0, + valid_attacker_ids: vec![attacker], + valid_attack_targets: vec![AttackTarget::Player(P1), AttackTarget::Planeswalker(other)], + valid_attack_targets_by_attacker: None, + attacker_constraints: Default::default(), + }; + runner + .act(GameAction::DeclareAttackers { + attacks: vec![(attacker, AttackTarget::Player(P1))], + bands: vec![], + }) + .expect("P1 is no longer the static source controller"); + assert!(!matches!( + runner.state().waiting_for, + WaitingFor::CombatTaxPayment { .. } + )); + + runner + .state_mut() + .objects + .get_mut(&attacker) + .unwrap() + .tapped = false; + runner.state_mut().waiting_for = WaitingFor::DeclareAttackers { + player: P0, + valid_attacker_ids: vec![attacker], + valid_attack_targets: vec![AttackTarget::Planeswalker(other)], + valid_attack_targets_by_attacker: None, + attacker_constraints: Default::default(), + }; + runner + .act(GameAction::DeclareAttackers { + attacks: vec![(attacker, AttackTarget::Planeswalker(other))], + bands: vec![], + }) + .expect("P2's planeswalker should now be taxed"); + assert!(matches!( + runner.state().waiting_for, + WaitingFor::CombatTaxPayment { .. } + )); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), oathkeeper, Zone::Graveyard, &mut events); + runner + .state_mut() + .objects + .get_mut(&attacker) + .unwrap() + .tapped = false; + runner.state_mut().waiting_for = WaitingFor::DeclareAttackers { + player: P0, + valid_attacker_ids: vec![attacker], + valid_attack_targets: vec![AttackTarget::Planeswalker(other)], + valid_attack_targets_by_attacker: None, + attacker_constraints: Default::default(), + }; + runner + .act(GameAction::DeclareAttackers { + attacks: vec![(attacker, AttackTarget::Planeswalker(other))], + bands: vec![], + }) + .expect("removing Oathkeeper removes its static tax"); + assert!(!matches!( + runner.state().waiting_for, + WaitingFor::CombatTaxPayment { .. } + )); +} + fn add_enchantment(scenario: &mut GameScenario, player: PlayerId, name: &str) -> ObjectId { scenario .add_creature(player, name, 2, 2) diff --git a/crates/engine/tests/integration/willie_lumpkin_cant_attack.rs b/crates/engine/tests/integration/willie_lumpkin_cant_attack.rs index 92a333b1e0..4f9936e5b8 100644 --- a/crates/engine/tests/integration/willie_lumpkin_cant_attack.rs +++ b/crates/engine/tests/integration/willie_lumpkin_cant_attack.rs @@ -22,7 +22,9 @@ use engine::game::combat::{declare_attackers, AttackTarget}; use engine::game::effects::add_restriction; -use engine::game::zones::create_object; +use engine::game::scenario::{GameRunner, GameScenario}; +use engine::game::triggers::process_triggers; +use engine::game::zones::{create_object, move_to_zone}; use engine::parser::oracle::parse_oracle_text; use engine::types::ability::{ AbilityDefinition, Duration, Effect, GameRestriction, PlayerScope, ProhibitedActivity, @@ -37,6 +39,7 @@ use engine::types::triggers::AttackTargetFilter; use engine::types::zones::Zone; const WILLIE_ORACLE: &str = "Willie Lumpkin can't be blocked.\nWhenever Willie Lumpkin deals combat damage to an opponent, you draw a card and that player may draw a card. If they do, that player can't attack you or permanents you control during their next turn."; +const PLANESWALKER_ONLY_WILLIE_ORACLE: &str = "Whenever this creature deals combat damage to an opponent, you draw a card and that player may draw a card. If they do, that player can't attack planeswalkers you control during their next turn."; const PROTECTED: PlayerId = PlayerId(0); // Willie's controller ("you") const RESTRICTED: PlayerId = PlayerId(1); // the opponent dealt damage @@ -294,6 +297,129 @@ fn willie_expiry_anchors_on_restricted_player_not_controller() { } } +/// CR 603.3 + CR 608.2c: the legacy anaphor route resolves through the normal +/// trigger/action pipeline and snapshots the damaged player, protected player, +/// and that player's next-turn expiry. Unlike the real Willie card's broader +/// permanent scope, this focused grammar fixture makes its planeswalker-only +/// target shape observable at runtime. +#[test] +fn legacy_willie_planeswalker_only_restriction_snapshots_selected_player() { + let mut scenario = GameScenario::new_n_player(3, 42); + let source = scenario + .add_creature_from_oracle( + PROTECTED, + "Legacy Willie Fixture", + 1, + 1, + PLANESWALKER_ONLY_WILLIE_ORACLE, + ) + .id(); + let restricted_attacker = scenario + .add_creature(RESTRICTED, "Restricted Bear", 2, 2) + .id(); + let protected_walker = scenario + .add_creature(PROTECTED, "Protected Jace", 0, 0) + .as_planeswalker_with_loyalty("Jace", 4) + .id(); + let other_walker = scenario + .add_creature(THIRD, "Other Chandra", 0, 0) + .as_planeswalker_with_loyalty("Chandra", 4) + .id(); + scenario.add_card_to_library_top(PROTECTED, "P0 draw fixture"); + scenario.add_card_to_library_top(RESTRICTED, "P1 draw fixture"); + let mut runner = scenario.build(); + process_triggers( + runner.state_mut(), + &[engine::types::events::GameEvent::DamageDealt { + source_id: source, + target: TargetRef::Player(RESTRICTED), + amount: 1, + is_combat: true, + excess: 0, + }], + ); + for _ in 0..24 { + match runner.state().waiting_for.clone() { + engine::types::game_state::WaitingFor::OptionalEffectChoice { .. } => { + runner + .act(engine::types::actions::GameAction::DecideOptionalEffect { accept: true }) + .expect("the damaged player accepts the legacy draw offer"); + } + engine::types::game_state::WaitingFor::Priority { .. } + if runner.state().stack.is_empty() => + { + break + } + engine::types::game_state::WaitingFor::Priority { .. } => { + runner + .act(engine::types::actions::GameAction::PassPriority) + .expect("priority resolves the trigger"); + } + other => panic!("unexpected legacy Willie prompt: {other:?}"), + } + } + + assert!(matches!( + runner.state().restrictions.as_slice(), + [GameRestriction::ProhibitActivity { + source: stored_source, + affected_players: RestrictionPlayerScope::SpecificPlayer(RESTRICTED), + expiry: RestrictionExpiry::UntilEndOfNextTurnOf { player: RESTRICTED }, + activity: ProhibitedActivity::Attack { + defended: AttackTargetFilter::Planeswalker, + protected_player: Some(PROTECTED), + }, + }] if *stored_source == source + )); + assert!(!attack_legal_from_runner( + &runner, + RESTRICTED, + restricted_attacker, + AttackTarget::Planeswalker(protected_walker) + )); + assert!(attack_legal_from_runner( + &runner, + RESTRICTED, + restricted_attacker, + AttackTarget::Player(PROTECTED) + )); + assert!(attack_legal_from_runner( + &runner, + RESTRICTED, + restricted_attacker, + AttackTarget::Planeswalker(other_walker) + )); + + runner + .state_mut() + .objects + .get_mut(&source) + .unwrap() + .controller = THIRD; + move_to_zone(runner.state_mut(), source, Zone::Graveyard, &mut Vec::new()); + assert!( + !attack_legal_from_runner( + &runner, + RESTRICTED, + restricted_attacker, + AttackTarget::Planeswalker(protected_walker) + ), + "source change/removal cannot mutate the resolved legacy snapshot" + ); +} + +fn attack_legal_from_runner( + runner: &GameRunner, + player: PlayerId, + attacker: ObjectId, + target: AttackTarget, +) -> bool { + let mut state = runner.state().clone(); + state.active_player = player; + let mut events = Vec::new(); + declare_attackers(&mut state, &[(attacker, target)], &mut events).is_ok() +} + /// MANDATORY no-over-application regression: an UNSCOPED `CantAttack` static /// (`attack_defended: None` — Propaganda/Pacifism family) must still reject the /// creature from attacking ANY target. This guards the `attack_defended.is_none()` diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index 5b5121675c..665774dacc 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -515,7 +515,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Olog-hai Crusher - Omega, Heartless Evolution - On Wings of Gold -- Onakke Oathkeeper - Only the Best - Oracle of Tragedy - Orcish Veteran