From 2883aae8a5c34121205ce932b60a615ba1f73daa Mon Sep 17 00:00:00 2001 From: traemyn Date: Thu, 13 Aug 2026 20:00:57 -0500 Subject: [PATCH 1/3] Fix Currency Converter conditional token branch --- .../src/parser/oracle_effect/assembly.rs | 39 ++++++++ .../issue_2871_currency_converter.rs | 99 +++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index e615abb86f..a390c40acb 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -2936,6 +2936,16 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { chain.kind = continuation_kind; // R2 — a SHAPE REPAIR, not materialization. normalize_linked_exile_cast_pair(&mut prev, &mut chain); + if prev.else_ability.is_none() + && are_complementary_revealed_card_type_conditions( + prev.condition.as_ref(), + chain.condition.as_ref(), + ) + { + prev.else_ability = Some(Box::new(chain)); + chain = prev; + continue; + } // CR 608.2c: an independent sentence after an if/otherwise choice // resolves after either branch (for example, Wedding Announcement's // three-counter transform also follows its Human-token branch). @@ -3147,6 +3157,35 @@ fn merge_search_tail_into_additional_cost_else( false } +// CR 608.2c: A positive card-type rider and its matching negated rider are +// complementary branches of the same instruction, not independent siblings. +fn are_complementary_revealed_card_type_conditions( + current: Option<&AbilityCondition>, + next: Option<&AbilityCondition>, +) -> bool { + let Some(AbilityCondition::RevealedHasCardType { + card_types: current_types, + additional_filter: current_filter, + subtype_filter: current_subtype, + }) = current + else { + return false; + }; + let Some(AbilityCondition::Not { condition }) = next else { + return false; + }; + let AbilityCondition::RevealedHasCardType { + card_types: next_types, + additional_filter: next_filter, + subtype_filter: next_subtype, + } = condition.as_ref() + else { + return false; + }; + + current_types == next_types && current_filter == next_filter && current_subtype == next_subtype +} + /// R2 — CR 608.2c + CR 401.4: linked-exile-cast bottom cleanup. /// /// After an optional `CastFromZone` from a linked exile, the trailing "put it on the diff --git a/crates/engine/tests/integration/issue_2871_currency_converter.rs b/crates/engine/tests/integration/issue_2871_currency_converter.rs index 4b5d34d543..2cd34cd140 100644 --- a/crates/engine/tests/integration/issue_2871_currency_converter.rs +++ b/crates/engine/tests/integration/issue_2871_currency_converter.rs @@ -4,7 +4,14 @@ //! https://github.com/phase-rs/phase/issues/2871 use engine::game::scenario::{GameScenario, P0}; +use engine::game::zones::create_object; +use engine::types::card_type::CoreType; +use engine::types::game_state::{ExileLink, ExileLinkKind}; +use engine::types::identifiers::CardId; use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const CURRENCY_CONVERTER_ABILITY: &str = "{T}: Put a card exiled with this artifact into its owner's graveyard. If it's a land card, create a Treasure token. If it's a nonland card, create a 2/2 black Rogue creature token."; #[test] fn issue_2871_currency_converter_tap_creates_no_token_without_exiled_card() { @@ -41,6 +48,98 @@ fn issue_2871_currency_converter_tap_creates_no_token_without_exiled_card() { ); } +#[test] +fn issue_2871_currency_converter_land_creates_treasure_only() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let converter = scenario + .add_creature(P0, "Currency Converter", 0, 0) + .as_artifact() + .from_oracle_text(CURRENCY_CONVERTER_ABILITY) + .id(); + + let mut runner = scenario.build(); + let state = runner.state_mut(); + let land = create_object(state, CardId(100), P0, "Mountain".to_string(), Zone::Exile); + { + let object = state.objects.get_mut(&land).expect("land exists"); + object.card_types.core_types.push(CoreType::Land); + object.base_card_types = object.card_types.clone(); + } + state.exile_links.push(ExileLink { + source_id: converter, + exiled_id: land, + kind: ExileLinkKind::TrackedBySource, + }); + + let treasure_before = count_battlefield_tokens(runner.state(), "Treasure"); + let rogue_before = count_battlefield_tokens(runner.state(), "Rogue"); + + runner.activate(converter, 0).resolve(); + + assert_eq!(runner.state().objects[&land].zone, Zone::Graveyard); + assert_eq!( + count_battlefield_tokens(runner.state(), "Treasure"), + treasure_before + 1, + "a land exiled with Currency Converter creates one Treasure token" + ); + assert_eq!( + count_battlefield_tokens(runner.state(), "Rogue"), + rogue_before, + "a land exiled with Currency Converter does not create a Rogue token" + ); +} + +#[test] +fn issue_2871_currency_converter_nonland_creates_rogue_only() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let converter = scenario + .add_creature(P0, "Currency Converter", 0, 0) + .as_artifact() + .from_oracle_text(CURRENCY_CONVERTER_ABILITY) + .id(); + + let mut runner = scenario.build(); + let state = runner.state_mut(); + let nonland = create_object( + state, + CardId(101), + P0, + "Grizzly Bears".to_string(), + Zone::Exile, + ); + { + let object = state.objects.get_mut(&nonland).expect("nonland exists"); + object.card_types.core_types.push(CoreType::Creature); + object.base_card_types = object.card_types.clone(); + } + state.exile_links.push(ExileLink { + source_id: converter, + exiled_id: nonland, + kind: ExileLinkKind::TrackedBySource, + }); + + let treasure_before = count_battlefield_tokens(runner.state(), "Treasure"); + let rogue_before = count_battlefield_tokens(runner.state(), "Rogue"); + + runner.activate(converter, 0).resolve(); + + assert_eq!(runner.state().objects[&nonland].zone, Zone::Graveyard); + assert_eq!( + count_battlefield_tokens(runner.state(), "Treasure"), + treasure_before, + "a nonland exiled with Currency Converter does not create a Treasure token" + ); + assert_eq!( + count_battlefield_tokens(runner.state(), "Rogue"), + rogue_before + 1, + "a nonland exiled with Currency Converter creates one Rogue token" + ); +} + fn count_battlefield_tokens(state: &engine::types::game_state::GameState, subtype: &str) -> usize { state .battlefield From 350872b62d29819e267b04a683c9faf8b1f3e13a Mon Sep 17 00:00:00 2001 From: traemyn Date: Fri, 14 Aug 2026 11:27:19 -0500 Subject: [PATCH 2/3] Support inverse card-type condition branches --- .../src/parser/oracle_effect/assembly.rs | 42 ++++++++++++------ .../issue_2871_currency_converter.rs | 44 +++++++++++++++++++ 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index a390c40acb..264f7f6e6b 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -3163,27 +3163,43 @@ fn are_complementary_revealed_card_type_conditions( current: Option<&AbilityCondition>, next: Option<&AbilityCondition>, ) -> bool { - let Some(AbilityCondition::RevealedHasCardType { - card_types: current_types, - additional_filter: current_filter, - subtype_filter: current_subtype, - }) = current + match (current, next) { + ( + Some(positive @ AbilityCondition::RevealedHasCardType { .. }), + Some(AbilityCondition::Not { condition }), + ) + | ( + Some(AbilityCondition::Not { condition }), + Some(positive @ AbilityCondition::RevealedHasCardType { .. }), + ) => same_revealed_card_type_condition(positive, condition.as_ref()), + _ => false, + } +} + +fn same_revealed_card_type_condition( + positive: &AbilityCondition, + negated: &AbilityCondition, +) -> bool { + let AbilityCondition::RevealedHasCardType { + card_types: positive_types, + additional_filter: positive_filter, + subtype_filter: positive_subtype, + } = positive else { return false; }; - let Some(AbilityCondition::Not { condition }) = next else { - return false; - }; let AbilityCondition::RevealedHasCardType { - card_types: next_types, - additional_filter: next_filter, - subtype_filter: next_subtype, - } = condition.as_ref() + card_types: negated_types, + additional_filter: negated_filter, + subtype_filter: negated_subtype, + } = negated else { return false; }; - current_types == next_types && current_filter == next_filter && current_subtype == next_subtype + positive_types == negated_types + && positive_filter == negated_filter + && positive_subtype == negated_subtype } /// R2 — CR 608.2c + CR 401.4: linked-exile-cast bottom cleanup. diff --git a/crates/engine/tests/integration/issue_2871_currency_converter.rs b/crates/engine/tests/integration/issue_2871_currency_converter.rs index 2cd34cd140..3cd49467e4 100644 --- a/crates/engine/tests/integration/issue_2871_currency_converter.rs +++ b/crates/engine/tests/integration/issue_2871_currency_converter.rs @@ -12,6 +12,7 @@ use engine::types::phase::Phase; use engine::types::zones::Zone; const CURRENCY_CONVERTER_ABILITY: &str = "{T}: Put a card exiled with this artifact into its owner's graveyard. If it's a land card, create a Treasure token. If it's a nonland card, create a 2/2 black Rogue creature token."; +const INVERSE_CARD_TYPE_RIDER_ABILITY: &str = "{T}: Put a card exiled with this artifact into its owner's graveyard. If it's a nonland card, create a 2/2 black Rogue creature token. If it's a land card, create a Treasure token."; #[test] fn issue_2871_currency_converter_tap_creates_no_token_without_exiled_card() { @@ -140,6 +141,49 @@ fn issue_2871_currency_converter_nonland_creates_rogue_only() { ); } +#[test] +fn issue_2871_inverse_card_type_riders_create_treasure_for_land() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let source = scenario + .add_creature(P0, "Inverse Card Type Riders", 0, 0) + .as_artifact() + .from_oracle_text(INVERSE_CARD_TYPE_RIDER_ABILITY) + .id(); + + let mut runner = scenario.build(); + let state = runner.state_mut(); + let land = create_object(state, CardId(102), P0, "Forest".to_string(), Zone::Exile); + { + let object = state.objects.get_mut(&land).expect("land exists"); + object.card_types.core_types.push(CoreType::Land); + object.base_card_types = object.card_types.clone(); + } + state.exile_links.push(ExileLink { + source_id: source, + exiled_id: land, + kind: ExileLinkKind::TrackedBySource, + }); + + let treasure_before = count_battlefield_tokens(runner.state(), "Treasure"); + let rogue_before = count_battlefield_tokens(runner.state(), "Rogue"); + + runner.activate(source, 0).resolve(); + + assert_eq!(runner.state().objects[&land].zone, Zone::Graveyard); + assert_eq!( + count_battlefield_tokens(runner.state(), "Treasure"), + treasure_before + 1, + "the positive second rider runs when the first negated rider is false" + ); + assert_eq!( + count_battlefield_tokens(runner.state(), "Rogue"), + rogue_before, + "a land does not create the first nonland rider's Rogue token" + ); +} + fn count_battlefield_tokens(state: &engine::types::game_state::GameState, subtype: &str) -> usize { state .battlefield From a270198a218b654cb7d664b25bc21e7b735f0702 Mon Sep 17 00:00:00 2001 From: traemyn Date: Fri, 14 Aug 2026 12:46:26 -0500 Subject: [PATCH 3/3] Test inverse nonland card-type branch --- .../issue_2871_currency_converter.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/engine/tests/integration/issue_2871_currency_converter.rs b/crates/engine/tests/integration/issue_2871_currency_converter.rs index 3cd49467e4..1f9869918c 100644 --- a/crates/engine/tests/integration/issue_2871_currency_converter.rs +++ b/crates/engine/tests/integration/issue_2871_currency_converter.rs @@ -184,6 +184,55 @@ fn issue_2871_inverse_card_type_riders_create_treasure_for_land() { ); } +#[test] +fn issue_2871_inverse_card_type_riders_create_rogue_for_nonland() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let source = scenario + .add_creature(P0, "Inverse Card Type Riders", 0, 0) + .as_artifact() + .from_oracle_text(INVERSE_CARD_TYPE_RIDER_ABILITY) + .id(); + + let mut runner = scenario.build(); + let state = runner.state_mut(); + let nonland = create_object( + state, + CardId(103), + P0, + "Grizzly Bears".to_string(), + Zone::Exile, + ); + { + let object = state.objects.get_mut(&nonland).expect("nonland exists"); + object.card_types.core_types.push(CoreType::Creature); + object.base_card_types = object.card_types.clone(); + } + state.exile_links.push(ExileLink { + source_id: source, + exiled_id: nonland, + kind: ExileLinkKind::TrackedBySource, + }); + + let treasure_before = count_battlefield_tokens(runner.state(), "Treasure"); + let rogue_before = count_battlefield_tokens(runner.state(), "Rogue"); + + runner.activate(source, 0).resolve(); + + assert_eq!(runner.state().objects[&nonland].zone, Zone::Graveyard); + assert_eq!( + count_battlefield_tokens(runner.state(), "Treasure"), + treasure_before, + "a nonland does not create the second land rider's Treasure token" + ); + assert_eq!( + count_battlefield_tokens(runner.state(), "Rogue"), + rogue_before + 1, + "the first negated rider creates one Rogue token for a nonland" + ); +} + fn count_battlefield_tokens(state: &engine::types::game_state::GameState, subtype: &str) -> usize { state .battlefield