Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions crates/engine/src/parser/oracle_effect/assembly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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).
Expand Down Expand Up @@ -3147,6 +3157,51 @@ 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 {
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 AbilityCondition::RevealedHasCardType {
card_types: negated_types,
additional_filter: negated_filter,
subtype_filter: negated_subtype,
} = negated
else {
return false;
};

positive_types == negated_types
&& positive_filter == negated_filter
&& positive_subtype == negated_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
Expand Down
192 changes: 192 additions & 0 deletions crates/engine/tests/integration/issue_2871_currency_converter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@
//! 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.";
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() {
Expand Down Expand Up @@ -41,6 +49,190 @@ 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"
);
}

#[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"
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[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
Expand Down
Loading