From fd672a687bba58068107484dba772c3208afc25b Mon Sep 17 00:00:00 2001 From: traemyn Date: Sat, 15 Aug 2026 07:46:00 -0500 Subject: [PATCH 1/5] Fix Crabomination emerge from artifact --- crates/engine/src/game/ability_scan.rs | 4 +- crates/engine/src/game/casting.rs | 107 +++++++++++++++------ crates/engine/src/game/casting_costs.rs | 73 ++++++++------ crates/engine/src/game/casting_tests.rs | 106 +++++++++++++++++++- crates/engine/src/game/triggers.rs | 1 + crates/engine/src/parser/oracle_keyword.rs | 40 +++++++- crates/engine/src/types/keywords.rs | 47 ++++++++- docs/parser-misparse-backlog.md | 1 - 8 files changed, 312 insertions(+), 67 deletions(-) diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 6818479450..66363f1be4 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -4643,6 +4643,7 @@ pub(crate) fn keyword_cost_reads_growing_class(kw: &Keyword) -> bool { | Keyword::Delve | Keyword::Craft { .. } | Keyword::Emerge(_) + | Keyword::EmergeFromQuality(_) | Keyword::Offering(_) | Keyword::Bargain | Keyword::Casualty(_) @@ -4914,7 +4915,8 @@ fn scan_keyword(kw: &Keyword, mode: ScanMode) -> Axes { | Keyword::Echo(_) | Keyword::Buyback(_) | Keyword::Cycling(_) - | Keyword::Flashback(_) => Axes::CONSERVATIVE, + | Keyword::Flashback(_) + | Keyword::EmergeFromQuality(_) => Axes::CONSERVATIVE, // Every other keyword carries a read-free payload (unit / u32 / String / // ManaCost / value tag): it reads nothing on any axis here. Its cost-read, // if any, is already captured by `cost_read` above. diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 6e68071f6a..a52dcb15f1 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -7,7 +7,7 @@ use crate::types::ability::{ ModalSelectionCondition, ObjectScope, PlayerFilter, PlayerScope, ProhibitedActivity, QuantityExpr, QuantityRef, ResolvedAbility, RestrictionExpiry, RestrictionPlayerScope, StaticCondition, StaticDefinition, SubAbilityLink, TapCreaturesRequirement, TargetFilter, - TargetRef, + TargetRef, TypedFilter, }; use crate::types::actions::{AlternativeCastDecision, GameAction}; use crate::types::card::LayoutKind; @@ -21,7 +21,7 @@ use crate::types::game_state::{ TargetSelectionSlot, WaitingFor, }; use crate::types::identifiers::{CardId, ObjectId, TrackedSetId}; -use crate::types::keywords::{FlashbackCost, Keyword, KeywordKind}; +use crate::types::keywords::{EmergeCost, FlashbackCost, Keyword, KeywordKind}; use crate::types::mana::{ ActivationManaColorConstraint, ManaColor, ManaCost, ManaCostShard, ManaSourceOutput, ManaSourceSelection, ManaSpellGrant, ManaType, PaymentContext, SpecialAction, SpellMeta, @@ -2547,6 +2547,22 @@ pub(crate) fn effective_spell_keywords( effective_spell_keywords_for(state, caster, object_id, false) } +/// CR 702.119a-b: The active Emerge keyword supplies the permanent quality for +/// its required sacrifice cost. +fn effective_emerge_sacrifice_filter( + state: &GameState, + caster: PlayerId, + object_id: ObjectId, +) -> Option { + effective_spell_keywords(state, caster, object_id) + .into_iter() + .find_map(|keyword| match keyword { + Keyword::Emerge(_) => Some(TargetFilter::Typed(TypedFilter::creature())), + Keyword::EmergeFromQuality(cost) => Some(cost.sacrifice_filter), + _ => None, + }) +} + /// Fuse-aware sibling of [`effective_spell_keywords`]. `fused` projects a /// pre-payment fused split spell with its COMBINED characteristics (CR 702.102b) /// so `CastWithKeyword`-granted keywords keyed on mana value / colors are granted @@ -5734,13 +5750,19 @@ fn casting_variant_candidates( candidates.push(CastingVariant::Overload); } - // CR 702.119a-c + CR 118.9: Emerge is a hand-zone alternative cost that - // requires sacrificing a creature and reducing the emerge cost by that - // creature's mana value. + // CR 702.119a-b + CR 118.9: Emerge is a hand-zone alternative cost that + // requires sacrificing its printed permanent quality and reducing the + // emerge cost by that permanent's mana value. if obj.zone == Zone::Hand && effective_spell_keywords(state, player, object_id) .iter() - .any(|k| matches!(k, crate::types::keywords::Keyword::Emerge(_))) + .any(|k| { + matches!( + k, + crate::types::keywords::Keyword::Emerge(_) + | crate::types::keywords::Keyword::EmergeFromQuality(_) + ) + }) { candidates.push(CastingVariant::Emerge); } @@ -6547,6 +6569,9 @@ fn prepare_spell_cast_with_variant_override_inner( .iter() .find_map(|k| match k { crate::types::keywords::Keyword::Emerge(cost) => Some(cost.clone()), + crate::types::keywords::Keyword::EmergeFromQuality(cost) => { + Some(cost.mana_cost.clone()) + } _ => None, }) } else { @@ -11549,26 +11574,38 @@ pub fn handle_cast_spell_with_payment_mode( } } - // CR 702.119a-c: Emerge — when a hand card has Keyword::Emerge and both + // CR 702.119a-b: Emerge — when a hand card has Keyword::Emerge and both // costs are affordable, present a choice. Emerge affordability includes a - // legal creature sacrifice and the reduced emerge cost after that - // sacrificed creature's mana value is subtracted. + // legal printed-quality sacrifice and the reduced emerge cost after that + // permanent's mana value is subtracted. if let Some(obj) = state.objects.get(&object_id) { if obj.zone == Zone::Hand { if let Some(emerge_cost) = effective_spell_keywords(state, player, object_id) .into_iter() .find_map(|k| match k { - crate::types::keywords::Keyword::Emerge(cost) => Some(cost), + crate::types::keywords::Keyword::Emerge(cost) => { + Some(EmergeCost::creature(cost)) + } + crate::types::keywords::Keyword::EmergeFromQuality(cost) => Some(cost), _ => None, }) { let (normal_cost, normal_affordable) = normal_cast_choice_cost_and_affordability(state, player, object_id, obj); - let emerge_cost_eff = - apply_cost_modifiers_to_base(state, player, object_id, emerge_cost.clone()) - .unwrap_or_else(|| emerge_cost.clone()); - let emerge_affordable = - casting_costs::can_pay_emerge_cost(state, player, object_id, &emerge_cost_eff); + let emerge_cost_eff = apply_cost_modifiers_to_base( + state, + player, + object_id, + emerge_cost.mana_cost.clone(), + ) + .unwrap_or_else(|| emerge_cost.mana_cost.clone()); + let emerge_affordable = casting_costs::can_pay_emerge_cost( + state, + player, + object_id, + &emerge_cost_eff, + &emerge_cost.sacrifice_filter, + ); if normal_affordable && emerge_affordable { return Ok(WaitingFor::AlternativeCastChoice { player, @@ -11578,7 +11615,9 @@ pub fn handle_cast_spell_with_payment_mode( keyword: crate::types::game_state::AlternativeCastKeyword::Emerge, normal_cost, alternative_cost: Some(emerge_cost_eff), - alternative_additional_cost: Some(casting_costs::emerge_sacrifice_cost()), + alternative_additional_cost: Some(casting_costs::emerge_sacrifice_cost( + emerge_cost.sacrifice_filter, + )), }); } if !normal_affordable && emerge_affordable { @@ -12843,11 +12882,13 @@ fn continue_with_prepared( )); } - // CR 702.119a-c + CR 601.2b/h: Emerge requires choosing which creature to - // sacrifice as the player chooses to pay the emerge cost, then sacrificing - // it as that cost is paid. Route this before any target selection so the - // required sacrifice is declared on the CR 601.2b axis. + // CR 702.119a-c + CR 601.2b/h: Emerge requires choosing the matching + // permanent to sacrifice as the player chooses to pay the emerge cost, + // then sacrificing it as that cost is paid. Route this before any target + // selection so the required sacrifice is declared on the CR 601.2b axis. if prepared.casting_variant == CastingVariant::Emerge { + let sacrifice_filter = effective_emerge_sacrifice_filter(state, player, prepared.object_id) + .expect("Emerge casting variant requires an effective Emerge keyword"); return casting_costs::begin_required_cost_before_targets( state, player, @@ -12856,7 +12897,7 @@ fn continue_with_prepared( resolved, prepared.mana_cost, Some(prepared.base_mana_cost.clone()), - casting_costs::emerge_sacrifice_cost(), + casting_costs::emerge_sacrifice_cost(sacrifice_filter), SpellCostSource::Emerge, prepared.casting_variant, prepared.casting_permission_index, @@ -13358,6 +13399,8 @@ fn continue_with_no_ability( player, ); if prepared.casting_variant == CastingVariant::Emerge { + let sacrifice_filter = effective_emerge_sacrifice_filter(state, player, prepared.object_id) + .expect("Emerge casting variant requires an effective Emerge keyword"); return casting_costs::begin_required_cost_before_targets( state, player, @@ -13366,7 +13409,7 @@ fn continue_with_no_ability( placeholder, prepared.mana_cost, Some(prepared.base_mana_cost.clone()), - casting_costs::emerge_sacrifice_cost(), + casting_costs::emerge_sacrifice_cost(sacrifice_filter), SpellCostSource::Emerge, prepared.casting_variant, prepared.casting_permission_index, @@ -14239,16 +14282,22 @@ fn can_cast_prepared_now_with_probe( return false; } - // CR 702.119a-c: Emerge affordability is the reduced emerge cost after - // sacrificing a legal creature, not the unreduced `prepared.mana_cost`. + // CR 702.119a-b: Emerge affordability is the reduced emerge cost after + // sacrificing a legal matching permanent, not the unreduced + // `prepared.mana_cost`. if prepared.casting_variant == CastingVariant::Emerge { return (prepared.modal.is_some() || spell_has_legal_targets_with_probe(state, obj.id, player, probe)) - && casting_costs::can_pay_emerge_cost( - state, - player, - prepared.object_id, - &prepared.mana_cost, + && effective_emerge_sacrifice_filter(state, player, prepared.object_id).is_some_and( + |sacrifice_filter| { + casting_costs::can_pay_emerge_cost( + state, + player, + prepared.object_id, + &prepared.mana_cost, + &sacrifice_filter, + ) + }, ); } diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 5fc4ef7961..b797a3ebfa 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -2906,7 +2906,7 @@ pub(crate) fn handle_sacrifice_for_cost( { Some(SpellCostSource::Offering) } else if payment.source == SpellCostSource::Emerge - && is_emerge_sacrifice_cost(payment.cost) + && is_emerge_sacrifice_cost(state, player, pending.object_id, payment.cost) { Some(SpellCostSource::Emerge) } else { @@ -7786,52 +7786,61 @@ fn is_offering_sacrifice_cost( ) } -fn emerge_sacrifice_filter() -> TargetFilter { - TargetFilter::Typed(TypedFilter::creature()) -} - -fn is_emerge_sacrifice_cost(cost: &AbilityCost) -> bool { +fn is_emerge_sacrifice_cost( + state: &GameState, + player: PlayerId, + object_id: ObjectId, + cost: &AbilityCost, +) -> bool { + let Some(sacrifice_filter) = super::casting::effective_spell_keywords(state, player, object_id) + .into_iter() + .find_map(|keyword| match keyword { + crate::types::keywords::Keyword::Emerge(_) => { + Some(TargetFilter::Typed(TypedFilter::creature())) + } + crate::types::keywords::Keyword::EmergeFromQuality(cost) => Some(cost.sacrifice_filter), + _ => None, + }) + else { + return false; + }; matches!( cost, AbilityCost::Sacrifice(cost) if cost.requirement == SacrificeRequirement::count(1) - && cost.target == emerge_sacrifice_filter() + && cost.target == sacrifice_filter ) } -/// CR 702.119a-c: Build the required sacrifice component of Emerge's -/// alternative cost. The sacrificed creature's mana value is applied as a cost -/// reduction by `handle_sacrifice_for_cost` while the creature is still on the +/// CR 702.119a-b: Build Emerge's required sacrifice component from its printed +/// permanent-quality filter. The sacrificed permanent's mana value is applied +/// as a cost reduction by `handle_sacrifice_for_cost` while it remains on the /// battlefield. -pub(super) fn emerge_sacrifice_cost() -> AbilityCost { - AbilityCost::Sacrifice(SacrificeCost::count(emerge_sacrifice_filter(), 1)) +pub(super) fn emerge_sacrifice_cost(sacrifice_filter: TargetFilter) -> AbilityCost { + AbilityCost::Sacrifice(SacrificeCost::count(sacrifice_filter, 1)) } -/// CR 702.119a-c: Emerge can be paid only if a legal creature can be +/// CR 702.119a-b: Emerge can be paid only if a matching permanent can be /// sacrificed and the resulting reduced emerge mana cost can be paid. pub(super) fn can_pay_emerge_cost( state: &GameState, player: PlayerId, object_id: ObjectId, emerge_cost: &ManaCost, + sacrifice_filter: &TargetFilter, ) -> bool { - super::casting::find_eligible_sacrifice_targets( - state, - player, - object_id, - &emerge_sacrifice_filter(), - ) - .into_iter() - .any(|creature| { - let mut reduced = emerge_cost.clone(); - apply_emerge_cost_reduction(state, creature, &mut reduced); - // CR 601.2f + CR 702.119a: Affordability probes must include the - // final Trinisphere-class floor after Emerge's sacrifice reduction. - if !cost_has_x(&reduced) { - super::casting::apply_cost_floor(state, player, object_id, &mut reduced); - } - super::casting::can_pay_cost_after_auto_tap(state, player, object_id, &reduced) - }) + super::casting::find_eligible_sacrifice_targets(state, player, object_id, sacrifice_filter) + .into_iter() + .any(|permanent| { + let mut reduced = emerge_cost.clone(); + apply_emerge_cost_reduction(state, permanent, &mut reduced); + // CR 601.2f + CR 702.119a: Affordability probes must include the + // final Trinisphere-class floor after Emerge's sacrifice reduction. + if !cost_has_x(&reduced) { + super::casting::apply_cost_floor(state, player, object_id, &mut reduced); + } + super::casting::can_pay_cost_after_auto_tap(state, player, object_id, &reduced) + }) } fn additional_cost_x_max( @@ -8477,8 +8486,8 @@ pub(super) fn apply_offering_cost_reduction( *spell_generic = spell_generic.saturating_sub(sac_generic); } -/// CR 702.119a: Reduce the Emerge cost by generic mana equal to the sacrificed -/// creature's mana value. Colored pips in the Emerge cost are never reduced. +/// CR 702.119a-b: Reduce the Emerge cost by generic mana equal to the sacrificed +/// permanent's mana value. Colored pips in the Emerge cost are never reduced. pub(super) fn apply_emerge_cost_reduction( state: &GameState, sacrifice_id: ObjectId, diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index d284a6f756..94c109be2f 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -21,7 +21,7 @@ use crate::types::card_type::{CoreType, Supertype}; use crate::types::counter::CounterType; use crate::types::events::GameEvent; use crate::types::game_state::{ManaChoice, ManaChoicePrompt, SpellCastRecord}; -use crate::types::keywords::{EscapeCost, FlashbackCost, Keyword, KeywordKind}; +use crate::types::keywords::{EmergeCost, EscapeCost, FlashbackCost, Keyword, KeywordKind}; use crate::types::mana::{ ManaColor, ManaCost, ManaCostShard, ManaRestriction, ManaSourceSelection, ManaSpellGrant, ManaType, ManaUnit, @@ -37234,6 +37234,43 @@ mod alt_cost_reduction_509 { obj_id } + fn create_artifact_emerge_spell( + state: &mut GameState, + player: PlayerId, + card_id: u64, + printed: ManaCost, + emerge: ManaCost, + ) -> ObjectId { + let spell = create_emerge_spell(state, player, card_id, printed, emerge.clone()); + state.objects.get_mut(&spell).unwrap().keywords = + vec![Keyword::EmergeFromQuality(EmergeCost::from_quality( + emerge, + TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), + ))]; + spell + } + + fn create_sacrifice_artifact( + state: &mut GameState, + player: PlayerId, + card_id: u64, + mana_cost: ManaCost, + ) -> ObjectId { + let artifact = create_object( + state, + CardId(card_id), + player, + "Sacrifice Artifact".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&artifact).unwrap(); + obj.card_types.core_types.push(CoreType::Artifact); + obj.base_card_types.core_types.push(CoreType::Artifact); + obj.mana_cost = mana_cost.clone(); + obj.base_mana_cost = mana_cost; + artifact + } + fn create_sacrifice_creature( state: &mut GameState, player: PlayerId, @@ -37919,6 +37956,73 @@ mod alt_cost_reduction_509 { ); } + /// CR 702.119b-c: Emerge from artifact must offer only qualifying artifacts + /// and reduce the emerge cost by the selected artifact's mana value. + #[test] + fn emerge_from_artifact_casts_after_sacrificing_an_artifact() { + let mut state = setup_game_at_main_phase(); + add_mana(&mut state, PlayerId(0), ManaType::Black, 2); + + let emerge = create_artifact_emerge_spell( + &mut state, + PlayerId(0), + 811, + ManaCost::generic(6), + ManaCost::Cost { + shards: vec![ManaCostShard::Black, ManaCostShard::Black], + generic: 5, + }, + ); + let artifact = + create_sacrifice_artifact(&mut state, PlayerId(0), 812, ManaCost::generic(5)); + let creature = + create_sacrifice_creature(&mut state, PlayerId(0), 813, ManaCost::generic(5)); + + assert!( + can_cast_object_now(&state, PlayerId(0), emerge), + "an artifact with mana value 5 must reduce {{5}}{{B}}{{B}} to payable {{B}}{{B}}" + ); + + let mut events = Vec::new(); + let waiting_for = + handle_cast_spell(&mut state, PlayerId(0), emerge, CardId(811), &mut events) + .expect("artifact emerge should enter sacrifice payment"); + match &waiting_for { + WaitingFor::PayCost { + kind: PayCostKind::Sacrifice, + choices, + .. + } => { + assert!( + choices.contains(&artifact), + "artifact must be a legal emerge sacrifice" + ); + assert!( + !choices.contains(&creature), + "a creature must not be legal for emerge from artifact" + ); + } + other => panic!("expected Emerge PayCost(Sacrifice), got {other:?}"), + } + + state.waiting_for = waiting_for; + apply_as_current( + &mut state, + GameAction::SelectCards { + cards: vec![artifact], + }, + ) + .expect("sacrificing the artifact should complete the emerge cast"); + + assert_eq!(state.objects[&artifact].zone, Zone::Graveyard); + assert_eq!(state.objects[&emerge].zone, Zone::Stack); + assert_eq!( + state.players[0].mana_pool.total(), + 0, + "artifact mana value must reduce the emerge cost before black mana is paid" + ); + } + #[test] fn emerge_mana_value_reduction_preserves_colored_pips() { let mut state = setup_game_at_main_phase(); diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index a89196f8d6..29480dc37c 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -34007,6 +34007,7 @@ pub mod tests { | Keyword::Miracle(_) | Keyword::Dash(_) | Keyword::Emerge(_) + | Keyword::EmergeFromQuality(_) | Keyword::Escape(_) | Keyword::Harmonize(_) | Keyword::Evoke(_) diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index 7c34362996..e1f7a98a8a 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -21,7 +21,7 @@ use crate::types::ability::{ }; use crate::types::keywords::{ normalize_bands_with_other_quality, BloodthirstValue, BuybackCost, CyclingCost, DisguiseCost, - EmbalmCost, EscapeCost, EternalizeCost, FlashbackCost, Keyword, WardCost, + EmbalmCost, EmergeCost, EscapeCost, EternalizeCost, FlashbackCost, Keyword, WardCost, }; use crate::types::mana::{ManaCost, ManaCostShard}; use crate::types::zones::Zone; @@ -1424,6 +1424,19 @@ pub(crate) fn parse_keyword_line_core(text: &str) -> Option<(Keyword, &str)> { return Some(result); } + // CR 702.119b: "Emerge from artifact {cost}" changes the required + // sacrifice permanent from Emerge's default creature to an artifact. + if let Ok((cost_text, _)) = tag::<_, _, OracleError<'_>>("emerge from artifact ").parse(text) { + let cost = crate::database::mtgjson::parse_mtgjson_mana_cost(cost_text); + return Some(( + Keyword::EmergeFromQuality(EmergeCost::from_quality( + cost, + TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), + )), + "", + )); + } + if let Some(kw) = parse_firebending_keyword_line(text) { return Some((kw, "")); } @@ -2534,7 +2547,7 @@ pub fn keyword_display_name(keyword: &Keyword) -> String { Keyword::Madness(_) => "madness".to_string(), Keyword::Miracle(_) => "miracle".to_string(), Keyword::Dash(_) => "dash".to_string(), - Keyword::Emerge(_) => "emerge".to_string(), + Keyword::Emerge(_) | Keyword::EmergeFromQuality(_) => "emerge".to_string(), Keyword::Escape(_) => "escape".to_string(), Keyword::Harmonize(_) => "harmonize".to_string(), Keyword::Mayhem(_) => "mayhem".to_string(), @@ -2885,6 +2898,29 @@ mod tests { use crate::types::mana::ManaCost; use crate::types::player::PlayerCounterKind; + #[test] + fn parse_keyword_line_core_emerge_from_artifact_preserves_quality() { + let (keyword, remainder) = parse_keyword_line_core("emerge from artifact {5}{b}{b}") + .expect("artifact-qualified Emerge must parse"); + assert!(remainder.is_empty()); + match keyword { + Keyword::EmergeFromQuality(EmergeCost { + mana_cost, + sacrifice_filter: TargetFilter::Typed(filter), + }) => { + assert_eq!( + mana_cost, + ManaCost::Cost { + shards: vec![ManaCostShard::Black, ManaCostShard::Black], + generic: 5, + } + ); + assert_eq!(filter.type_filters, vec![TypeFilter::Artifact]); + } + other => panic!("expected artifact-qualified Emerge, got {other:?}"), + } + } + #[test] fn ward_get_poison_counters_parses_as_player_counter_cost() { // Issue #6640 (The Serpent Society): "Ward—Get five poison counters." diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index 4e4780cb50..a287572e56 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -116,6 +116,37 @@ pub enum BestowCost { NonMana(AbilityCost), } +/// CR 702.119a-b: Emerge's mana cost and the permanent quality required for +/// its sacrifice cost. Ordinary emerge sacrifices a creature; "emerge from +/// [quality]" uses the printed permanent filter instead. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EmergeCost { + pub mana_cost: ManaCost, + pub sacrifice_filter: TargetFilter, +} + +impl EmergeCost { + pub fn creature(mana_cost: ManaCost) -> Self { + Self { + mana_cost, + sacrifice_filter: TargetFilter::Typed(TypedFilter::creature()), + } + } + + pub fn from_quality(mana_cost: ManaCost, sacrifice_filter: TargetFilter) -> Self { + Self { + mana_cost, + sacrifice_filter, + } + } +} + +impl Default for EmergeCost { + fn default() -> Self { + Self::creature(ManaCost::default()) + } +} + /// CR 702.138a + CR 118.9 + CR 601.2f-h: Escape cost — an alternative cost paid /// to cast a card from the graveyard (CR 702.138a). Almost always a compound /// cost: a mana sub-cost plus "Exile N other cards from your graveyard". A few @@ -756,9 +787,12 @@ pub enum Keyword { /// `CastingVariant::Miracle` with the miracle mana cost. Miracle(ManaCost), Dash(ManaCost), - /// CR 702.119a-c: Emerge is an alternative cost paid by sacrificing a + /// CR 702.119a: Emerge is an alternative cost paid by sacrificing a /// creature and reducing the emerge cost by that creature's mana value. Emerge(ManaCost), + /// CR 702.119b: "Emerge from [quality]" uses the printed permanent + /// quality instead of ordinary Emerge's creature requirement. + EmergeFromQuality(EmergeCost), /// CR 702.138a: Escape — cast from graveyard for an alternative cost. The /// compound escape cost (mana sub-cost plus one or more exile sub-costs) is /// modeled by `EscapeCost` and split at runtime by @@ -1319,6 +1353,7 @@ impl Keyword { | Keyword::Miracle(_) | Keyword::Dash(_) | Keyword::Emerge(_) + | Keyword::EmergeFromQuality(_) | Keyword::Escape(_) | Keyword::Harmonize(_) | Keyword::Evoke(_) @@ -1596,6 +1631,7 @@ impl Keyword { | Keyword::DoubleTeam | Keyword::Echo(_) | Keyword::Emerge(_) + | Keyword::EmergeFromQuality(_) | Keyword::Encore(_) | Keyword::Enlist | Keyword::Entwine(_) @@ -1684,6 +1720,7 @@ impl Keyword { | Keyword::Cipher | Keyword::Evoke(_) | Keyword::Emerge(_) + | Keyword::EmergeFromQuality(_) | Keyword::Bestow(_) | Keyword::Madness(_) | Keyword::Suspend { .. } @@ -3232,6 +3269,9 @@ fn keyword_from_tagged(variant: &str, data: &serde_json::Value) -> Result Ok(Keyword::Miracle(mana(data)?)), "Dash" => Ok(Keyword::Dash(mana(data)?)), "Emerge" => Ok(Keyword::Emerge(mana(data)?)), + "EmergeFromQuality" => serde_json::from_value(data.clone()) + .map(Keyword::EmergeFromQuality) + .map_err(|error| format!("EmergeFromQuality: {error}")), "Harmonize" => Ok(Keyword::Harmonize(mana(data)?)), // CR 702.138a: MTGJSON provides bare "Escape" with no structured cost data. // Accept both legacy ManaCost format and new EscapeCost tagged format @@ -5154,6 +5194,10 @@ mod tests { Keyword::Miracle(mc("{2}{R}")), Keyword::Dash(mc("{2}{R}")), Keyword::Emerge(mc("{2}{R}")), + Keyword::EmergeFromQuality(EmergeCost::from_quality( + mc("{2}{R}"), + TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), + )), Keyword::Escape(EscapeCost::NonMana(pay_life_cost())), Keyword::Harmonize(mc("{2}{R}")), Keyword::Evoke(EvokeCost::NonMana(pay_life_cost())), @@ -5421,6 +5465,7 @@ mod tests { Keyword::Miracle(..) => Some("Miracle"), Keyword::Dash(..) => Some("Dash"), Keyword::Emerge(..) => Some("Emerge"), + Keyword::EmergeFromQuality(..) => Some("EmergeFromQuality"), Keyword::Escape(..) => Some("Escape"), Keyword::Harmonize(..) => Some("Harmonize"), Keyword::Evoke(..) => Some("Evoke"), diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index a525bf96e5..7445e6e550 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -1877,7 +1877,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Corruption of Towashi - Cosmic Horror - Covenant of Minds -- Crabomination - Crosis, the Purger - Cry of the Carnarium - Cunning Nightbonder From f185fdad62ddd6a2b01b80250a2f3bb160826fc6 Mon Sep 17 00:00:00 2001 From: traemyn Date: Sat, 15 Aug 2026 08:53:12 -0500 Subject: [PATCH 2/5] Fix qualified emerge parsing --- crates/engine/src/game/ability_scan.rs | 4 +- crates/engine/src/game/casting.rs | 25 ++----- crates/engine/src/game/casting_costs.rs | 5 +- crates/engine/src/game/casting_tests.rs | 7 +- crates/engine/src/game/triggers.rs | 1 - crates/engine/src/parser/oracle_keyword.rs | 80 +++++++++++++++++---- crates/engine/src/types/keywords.rs | 30 ++++---- crates/mtgish-import/src/convert/keyword.rs | 4 +- 8 files changed, 97 insertions(+), 59 deletions(-) diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 66363f1be4..2ece208062 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -4643,7 +4643,6 @@ pub(crate) fn keyword_cost_reads_growing_class(kw: &Keyword) -> bool { | Keyword::Delve | Keyword::Craft { .. } | Keyword::Emerge(_) - | Keyword::EmergeFromQuality(_) | Keyword::Offering(_) | Keyword::Bargain | Keyword::Casualty(_) @@ -4916,7 +4915,7 @@ fn scan_keyword(kw: &Keyword, mode: ScanMode) -> Axes { | Keyword::Buyback(_) | Keyword::Cycling(_) | Keyword::Flashback(_) - | Keyword::EmergeFromQuality(_) => Axes::CONSERVATIVE, + | Keyword::Emerge(_) => Axes::CONSERVATIVE, // Every other keyword carries a read-free payload (unit / u32 / String / // ManaCost / value tag): it reads nothing on any axis here. Its cost-read, // if any, is already captured by `cost_read` above. @@ -5002,7 +5001,6 @@ fn scan_keyword(kw: &Keyword, mode: ScanMode) -> Axes { | Keyword::Madness(_) | Keyword::Miracle(_) | Keyword::Dash(_) - | Keyword::Emerge(_) | Keyword::Harmonize(_) | Keyword::Foretell(_) | Keyword::Mutate(_) diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index a52dcb15f1..92c782bde4 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -7,7 +7,7 @@ use crate::types::ability::{ ModalSelectionCondition, ObjectScope, PlayerFilter, PlayerScope, ProhibitedActivity, QuantityExpr, QuantityRef, ResolvedAbility, RestrictionExpiry, RestrictionPlayerScope, StaticCondition, StaticDefinition, SubAbilityLink, TapCreaturesRequirement, TargetFilter, - TargetRef, TypedFilter, + TargetRef, }; use crate::types::actions::{AlternativeCastDecision, GameAction}; use crate::types::card::LayoutKind; @@ -21,7 +21,7 @@ use crate::types::game_state::{ TargetSelectionSlot, WaitingFor, }; use crate::types::identifiers::{CardId, ObjectId, TrackedSetId}; -use crate::types::keywords::{EmergeCost, FlashbackCost, Keyword, KeywordKind}; +use crate::types::keywords::{FlashbackCost, Keyword, KeywordKind}; use crate::types::mana::{ ActivationManaColorConstraint, ManaColor, ManaCost, ManaCostShard, ManaSourceOutput, ManaSourceSelection, ManaSpellGrant, ManaType, PaymentContext, SpecialAction, SpellMeta, @@ -2557,8 +2557,7 @@ fn effective_emerge_sacrifice_filter( effective_spell_keywords(state, caster, object_id) .into_iter() .find_map(|keyword| match keyword { - Keyword::Emerge(_) => Some(TargetFilter::Typed(TypedFilter::creature())), - Keyword::EmergeFromQuality(cost) => Some(cost.sacrifice_filter), + Keyword::Emerge(cost) => Some(cost.sacrifice_filter), _ => None, }) } @@ -5756,13 +5755,7 @@ fn casting_variant_candidates( if obj.zone == Zone::Hand && effective_spell_keywords(state, player, object_id) .iter() - .any(|k| { - matches!( - k, - crate::types::keywords::Keyword::Emerge(_) - | crate::types::keywords::Keyword::EmergeFromQuality(_) - ) - }) + .any(|k| matches!(k, crate::types::keywords::Keyword::Emerge(_))) { candidates.push(CastingVariant::Emerge); } @@ -6568,10 +6561,7 @@ fn prepare_spell_cast_with_variant_override_inner( effective_spell_keywords(state, player, object_id) .iter() .find_map(|k| match k { - crate::types::keywords::Keyword::Emerge(cost) => Some(cost.clone()), - crate::types::keywords::Keyword::EmergeFromQuality(cost) => { - Some(cost.mana_cost.clone()) - } + crate::types::keywords::Keyword::Emerge(cost) => Some(cost.mana_cost.clone()), _ => None, }) } else { @@ -11583,10 +11573,7 @@ pub fn handle_cast_spell_with_payment_mode( if let Some(emerge_cost) = effective_spell_keywords(state, player, object_id) .into_iter() .find_map(|k| match k { - crate::types::keywords::Keyword::Emerge(cost) => { - Some(EmergeCost::creature(cost)) - } - crate::types::keywords::Keyword::EmergeFromQuality(cost) => Some(cost), + crate::types::keywords::Keyword::Emerge(cost) => Some(cost), _ => None, }) { diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index b797a3ebfa..9ce94ea539 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -7795,10 +7795,7 @@ fn is_emerge_sacrifice_cost( let Some(sacrifice_filter) = super::casting::effective_spell_keywords(state, player, object_id) .into_iter() .find_map(|keyword| match keyword { - crate::types::keywords::Keyword::Emerge(_) => { - Some(TargetFilter::Typed(TypedFilter::creature())) - } - crate::types::keywords::Keyword::EmergeFromQuality(cost) => Some(cost.sacrifice_filter), + crate::types::keywords::Keyword::Emerge(cost) => Some(cost.sacrifice_filter), _ => None, }) else { diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 94c109be2f..b7e81db51c 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -37230,7 +37230,8 @@ mod alt_cost_reduction_509 { obj.base_power = Some(5); obj.base_toughness = Some(5); obj.base_characteristics_initialized = true; - obj.keywords.push(Keyword::Emerge(emerge)); + obj.keywords + .push(Keyword::Emerge(EmergeCost::creature(emerge))); obj_id } @@ -37243,7 +37244,7 @@ mod alt_cost_reduction_509 { ) -> ObjectId { let spell = create_emerge_spell(state, player, card_id, printed, emerge.clone()); state.objects.get_mut(&spell).unwrap().keywords = - vec![Keyword::EmergeFromQuality(EmergeCost::from_quality( + vec![Keyword::Emerge(EmergeCost::from_quality( emerge, TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), ))]; @@ -37976,7 +37977,7 @@ mod alt_cost_reduction_509 { let artifact = create_sacrifice_artifact(&mut state, PlayerId(0), 812, ManaCost::generic(5)); let creature = - create_sacrifice_creature(&mut state, PlayerId(0), 813, ManaCost::generic(5)); + create_sacrifice_creature(&mut state, PlayerId(0), 813, ManaCost::generic(1)); assert!( can_cast_object_now(&state, PlayerId(0), emerge), diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 29480dc37c..a89196f8d6 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -34007,7 +34007,6 @@ pub mod tests { | Keyword::Miracle(_) | Keyword::Dash(_) | Keyword::Emerge(_) - | Keyword::EmergeFromQuality(_) | Keyword::Escape(_) | Keyword::Harmonize(_) | Keyword::Evoke(_) diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index e1f7a98a8a..d39055a215 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -1424,17 +1424,13 @@ pub(crate) fn parse_keyword_line_core(text: &str) -> Option<(Keyword, &str)> { return Some(result); } - // CR 702.119b: "Emerge from artifact {cost}" changes the required - // sacrifice permanent from Emerge's default creature to an artifact. - if let Ok((cost_text, _)) = tag::<_, _, OracleError<'_>>("emerge from artifact ").parse(text) { - let cost = crate::database::mtgjson::parse_mtgjson_mana_cost(cost_text); - return Some(( - Keyword::EmergeFromQuality(EmergeCost::from_quality( - cost, - TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), - )), - "", - )); + // CR 702.119b: "Emerge from [quality] {cost}" replaces ordinary Emerge's + // creature sacrifice with a permanent matching the parsed quality. + if tag::<_, _, OracleError<'_>>("emerge from ") + .parse(text) + .is_ok() + { + return parse_emerge_from_quality_keyword_line(text); } if let Some(kw) = parse_firebending_keyword_line(text) { @@ -1963,6 +1959,27 @@ pub(crate) fn parse_keyword_line_core(text: &str) -> Option<(Keyword, &str)> { Some((parsed, unconsumed)) } +/// CR 702.119b: Parse "emerge from [quality] {cost}" without swallowing a +/// missing mana cost or semantic suffix. The type parser owns the quality grammar +/// and the mana combinator leaves any trailing text for the strict router. +fn parse_emerge_from_quality_keyword_line(text: &str) -> Option<(Keyword, &str)> { + let (after_prefix, _) = tag::<_, _, OracleError<'_>>("emerge from ") + .parse(text) + .ok()?; + let (sacrifice_filter, after_quality) = parse_type_phrase(after_prefix); + if after_quality.len() == after_prefix.len() { + return None; + } + let (after_cost, _) = space1::<_, OracleError<'_>>.parse(after_quality).ok()?; + let upper_cost = after_cost.to_ascii_uppercase(); + let (upper_remainder, mana_cost) = nom_primitives::parse_mana_cost(&upper_cost).ok()?; + let remainder = &after_cost[after_cost.len() - upper_remainder.len()..]; + Some(( + Keyword::Emerge(EmergeCost::from_quality(mana_cost, sacrifice_filter)), + remainder, + )) +} + /// Permissive, grant-context keyword parser. Returns the typed leading keyword /// and **deliberately discards** whatever the core did not consume. /// @@ -2547,7 +2564,7 @@ pub fn keyword_display_name(keyword: &Keyword) -> String { Keyword::Madness(_) => "madness".to_string(), Keyword::Miracle(_) => "miracle".to_string(), Keyword::Dash(_) => "dash".to_string(), - Keyword::Emerge(_) | Keyword::EmergeFromQuality(_) => "emerge".to_string(), + Keyword::Emerge(_) => "emerge".to_string(), Keyword::Escape(_) => "escape".to_string(), Keyword::Harmonize(_) => "harmonize".to_string(), Keyword::Mayhem(_) => "mayhem".to_string(), @@ -2904,7 +2921,7 @@ mod tests { .expect("artifact-qualified Emerge must parse"); assert!(remainder.is_empty()); match keyword { - Keyword::EmergeFromQuality(EmergeCost { + Keyword::Emerge(EmergeCost { mana_cost, sacrifice_filter: TargetFilter::Typed(filter), }) => { @@ -2921,6 +2938,43 @@ mod tests { } } + #[test] + fn parse_keyword_line_core_emerge_from_creature_preserves_quality() { + let (keyword, remainder) = parse_keyword_line_core("emerge from creature {3}{u}") + .expect("creature-qualified Emerge must parse"); + assert!(remainder.is_empty()); + match keyword { + Keyword::Emerge(EmergeCost { + mana_cost, + sacrifice_filter: TargetFilter::Typed(filter), + }) => { + assert_eq!( + mana_cost, + ManaCost::Cost { + shards: vec![ManaCostShard::Blue], + generic: 3, + } + ); + assert_eq!(filter.type_filters, vec![TypeFilter::Creature]); + } + other => panic!("expected creature-qualified Emerge, got {other:?}"), + } + } + + #[test] + fn parse_keyword_line_core_emerge_from_quality_requires_mana_cost() { + assert!(parse_keyword_line_core("emerge from artifact").is_none()); + } + + #[test] + fn parse_router_keyword_line_emerge_from_quality_rejects_semantic_suffix() { + assert!( + parse_router_keyword_line("Emerge from artifact {5} if you control an Island") + .is_none(), + "a semantic suffix must remain unconsumed so the strict router declines the line" + ); + } + #[test] fn ward_get_poison_counters_parses_as_player_counter_cost() { // Issue #6640 (The Serpent Society): "Ward—Get five poison counters." diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index a287572e56..61746efd34 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -787,12 +787,10 @@ pub enum Keyword { /// `CastingVariant::Miracle` with the miracle mana cost. Miracle(ManaCost), Dash(ManaCost), - /// CR 702.119a: Emerge is an alternative cost paid by sacrificing a - /// creature and reducing the emerge cost by that creature's mana value. - Emerge(ManaCost), - /// CR 702.119b: "Emerge from [quality]" uses the printed permanent - /// quality instead of ordinary Emerge's creature requirement. - EmergeFromQuality(EmergeCost), + /// CR 702.119a-b: Emerge is an alternative cost paid by sacrificing the + /// specified permanent quality and reducing the emerge cost by that + /// permanent's mana value. + Emerge(EmergeCost), /// CR 702.138a: Escape — cast from graveyard for an alternative cost. The /// compound escape cost (mana sub-cost plus one or more exile sub-costs) is /// modeled by `EscapeCost` and split at runtime by @@ -1353,7 +1351,6 @@ impl Keyword { | Keyword::Miracle(_) | Keyword::Dash(_) | Keyword::Emerge(_) - | Keyword::EmergeFromQuality(_) | Keyword::Escape(_) | Keyword::Harmonize(_) | Keyword::Evoke(_) @@ -1631,7 +1628,6 @@ impl Keyword { | Keyword::DoubleTeam | Keyword::Echo(_) | Keyword::Emerge(_) - | Keyword::EmergeFromQuality(_) | Keyword::Encore(_) | Keyword::Enlist | Keyword::Entwine(_) @@ -1720,7 +1716,6 @@ impl Keyword { | Keyword::Cipher | Keyword::Evoke(_) | Keyword::Emerge(_) - | Keyword::EmergeFromQuality(_) | Keyword::Bestow(_) | Keyword::Madness(_) | Keyword::Suspend { .. } @@ -2408,7 +2403,11 @@ impl FromStr for Keyword { "madness" => return Ok(Keyword::Madness(parse_keyword_mana_cost(p))), "miracle" => return Ok(Keyword::Miracle(parse_keyword_mana_cost(p))), "dash" => return Ok(Keyword::Dash(parse_keyword_mana_cost(p))), - "emerge" => return Ok(Keyword::Emerge(parse_keyword_mana_cost(p))), + "emerge" => { + return Ok(Keyword::Emerge(EmergeCost::creature( + parse_keyword_mana_cost(p), + ))) + } "harmonize" => return Ok(Keyword::Harmonize(parse_keyword_mana_cost(p))), "escape" => { // CR 702.138a: MTGJSON's keywords array carries only the bare @@ -3268,9 +3267,12 @@ fn keyword_from_tagged(variant: &str, data: &serde_json::Value) -> Result Ok(Keyword::Madness(mana(data)?)), "Miracle" => Ok(Keyword::Miracle(mana(data)?)), "Dash" => Ok(Keyword::Dash(mana(data)?)), - "Emerge" => Ok(Keyword::Emerge(mana(data)?)), + "Emerge" => match serde_json::from_value::(data.clone()) { + Ok(cost) => Ok(Keyword::Emerge(cost)), + Err(_) => Ok(Keyword::Emerge(EmergeCost::creature(mana(data)?))), + }, "EmergeFromQuality" => serde_json::from_value(data.clone()) - .map(Keyword::EmergeFromQuality) + .map(Keyword::Emerge) .map_err(|error| format!("EmergeFromQuality: {error}")), "Harmonize" => Ok(Keyword::Harmonize(mana(data)?)), // CR 702.138a: MTGJSON provides bare "Escape" with no structured cost data. @@ -5193,8 +5195,7 @@ mod tests { Keyword::Madness(mc("{2}{R}")), Keyword::Miracle(mc("{2}{R}")), Keyword::Dash(mc("{2}{R}")), - Keyword::Emerge(mc("{2}{R}")), - Keyword::EmergeFromQuality(EmergeCost::from_quality( + Keyword::Emerge(EmergeCost::from_quality( mc("{2}{R}"), TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), )), @@ -5465,7 +5466,6 @@ mod tests { Keyword::Miracle(..) => Some("Miracle"), Keyword::Dash(..) => Some("Dash"), Keyword::Emerge(..) => Some("Emerge"), - Keyword::EmergeFromQuality(..) => Some("EmergeFromQuality"), Keyword::Escape(..) => Some("Escape"), Keyword::Harmonize(..) => Some("Harmonize"), Keyword::Evoke(..) => Some("Evoke"), diff --git a/crates/mtgish-import/src/convert/keyword.rs b/crates/mtgish-import/src/convert/keyword.rs index 64028bea65..d88b8e4c93 100644 --- a/crates/mtgish-import/src/convert/keyword.rs +++ b/crates/mtgish-import/src/convert/keyword.rs @@ -177,7 +177,9 @@ pub fn try_convert(rule: &Rule, path: &str) -> ConvResult> { "Rule::Embalm", path, )?)), - Rule::Emerge(c) => Keyword::Emerge(pure_mana(c, "Rule::Emerge", path)?), + Rule::Emerge(c) => Keyword::Emerge(engine::types::keywords::EmergeCost::creature( + pure_mana(c, "Rule::Emerge", path)?, + )), Rule::Encore(c) => Keyword::Encore(pure_mana(c, "Rule::Encore", path)?), Rule::Eternalize(c) => Keyword::Eternalize(engine::types::keywords::EternalizeCost::Mana( pure_mana(c, "Rule::Eternalize", path)?, From 98268e66ae3d7a535133620360ee2349a014f759 Mon Sep 17 00:00:00 2001 From: traemyn Date: Sat, 15 Aug 2026 11:08:16 -0500 Subject: [PATCH 3/5] Address Emerge review feedback --- client/src/adapter/types.ts | 2 +- .../components/modal/AlternativeCostModal.tsx | 17 ++- .../__tests__/AlternativeCostModal.test.tsx | 13 ++- client/src/i18n/locales/de/game.json | 3 +- client/src/i18n/locales/en/game.json | 3 +- client/src/i18n/locales/es/game.json | 3 +- client/src/i18n/locales/fr/game.json | 3 +- client/src/i18n/locales/it/game.json | 3 +- client/src/i18n/locales/pl/game.json | 3 +- client/src/i18n/locales/pt/game.json | 3 +- crates/engine/src/game/casting.rs | 57 +++++++++- crates/engine/src/game/casting_tests.rs | 101 ++++++++++++++++++ crates/engine/src/types/game_state.rs | 4 + crates/engine/src/types/keywords.rs | 26 +++++ .../tests/integration/interaction_contract.rs | 1 + crates/mtgish-import/src/convert/keyword.rs | 1 + 16 files changed, 227 insertions(+), 16 deletions(-) diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 3b254d7f93..5f3f49ea08 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1748,7 +1748,7 @@ export type WaitingFor = // `keyword.type` mirrors engine `AlternativeCastKeyword` (game_state.rs) 1:1. // Keep this union exhaustive with the engine enum so the modal's keyword // switch is type-checked against every variant the engine can emit. - | { type: "AlternativeCastChoice"; data: { player: PlayerId; object_id: ObjectId; card_id: CardId; payment_mode?: CastPaymentMode; keyword: { type: "Warp" } | { type: "Evoke" } | { type: "Emerge" } | { type: "Dash" } | { type: "Blitz" } | { type: "Overload" } | { type: "Bestow" } | { type: "Awaken" } | { type: "Cleave" } | { type: "MoreThanMeetsTheEye" } | { type: "Impending" } | { type: "Prototype" } | { type: "Mutate" } | { type: "Spectacle" } | { type: "Prowl" } | { type: "FaceDown" }; normal_cost: ManaCost; alternative_cost: ManaCost | null; alternative_additional_cost: SerializedAbilityCost | null } } + | { type: "AlternativeCastChoice"; data: { player: PlayerId; object_id: ObjectId; card_id: CardId; payment_mode?: CastPaymentMode; keyword: { type: "Warp" } | { type: "Evoke" } | { type: "Emerge" } | { type: "Dash" } | { type: "Blitz" } | { type: "Overload" } | { type: "Bestow" } | { type: "Awaken" } | { type: "Cleave" } | { type: "MoreThanMeetsTheEye" } | { type: "Impending" } | { type: "Prototype" } | { type: "Mutate" } | { type: "Spectacle" } | { type: "Prowl" } | { type: "FaceDown" }; normal_cost: ManaCost; alternative_cost: ManaCost | null; alternative_additional_cost: SerializedAbilityCost | null; alternative_additional_cost_description: string | null } } // CR 702.140c + CR 730.2a: mutating creature spell resolving with a legal // target — controller chooses to put it on top of or under the target creature. | { type: "MutateMergeChoice"; data: { player: PlayerId; merging_id: ObjectId; target_id: ObjectId } } diff --git a/client/src/components/modal/AlternativeCostModal.tsx b/client/src/components/modal/AlternativeCostModal.tsx index e0f5086d3b..c017b3af2c 100644 --- a/client/src/components/modal/AlternativeCostModal.tsx +++ b/client/src/components/modal/AlternativeCostModal.tsx @@ -35,6 +35,7 @@ interface KeywordCopy { function keywordCopy( keyword: Keyword, cardName: string, + alternativeAdditionalCostDescription: string | null, t: TFunction<"game">, ): KeywordCopy { switch (keyword) { @@ -55,15 +56,18 @@ function keywordCopy( showOracleText: true, subtitle: t("alternativeCost.evokeSubtitle", { name: cardName }), }; - // CR 702.119a-c: Emerge — sacrifice a creature while casting; the emerge - // cost is reduced by that creature's mana value (handled engine-side). + // CR 702.119a-b: Emerge's required sacrifice quality is supplied by the + // engine; the modal must not infer it from the typed cost filter. case "Emerge": return { eyebrow: t("alternativeCost.emergeEyebrow"), normalLabel: t("alternativeCost.emergeNormalLabel"), altLabel: t("alternativeCost.emergeAltLabel"), showOracleText: true, - subtitle: t("alternativeCost.emergeSubtitle", { name: cardName }), + subtitle: t("alternativeCost.emergeSubtitle", { + name: cardName, + sacrifice: alternativeAdditionalCostDescription ?? t("alternativeCost.emergeFallbackSacrifice"), + }), }; // CR 702.109a: Dash — like Warp, the rider (haste + end-step return to hand) // lives on the keyword itself and doesn't change the spell's printed text. @@ -243,6 +247,7 @@ export function AlternativeCostModal() { normalCost={data.normal_cost} alternativeCost={data.alternative_cost} alternativeAdditionalCost={data.alternative_additional_cost} + alternativeAdditionalCostDescription={data.alternative_additional_cost_description} dispatch={dispatch} /> ); @@ -254,6 +259,7 @@ function AlternativeCostContent({ normalCost, alternativeCost, alternativeAdditionalCost, + alternativeAdditionalCostDescription, dispatch, }: { objectId: number; @@ -261,6 +267,7 @@ function AlternativeCostContent({ normalCost: ManaCost; alternativeCost: ManaCost | null; alternativeAdditionalCost: SerializedAbilityCost | null; + alternativeAdditionalCostDescription: string | null; dispatch: (action: GameAction) => Promise; }) { const { t } = useTranslation("game"); @@ -269,7 +276,7 @@ function AlternativeCostContent({ if (!obj) return null; const cardName = obj.name; - const copy = keywordCopy(keyword, cardName, t); + const copy = keywordCopy(keyword, cardName, alternativeAdditionalCostDescription, t); return ( - {describeAdditionalCost(alternativeAdditionalCost, t)} + {alternativeAdditionalCostDescription ?? describeAdditionalCost(alternativeAdditionalCost, t)} )} {copy.altSuffix && ( diff --git a/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx b/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx index 58b65a7a11..b2e984e4dd 100644 --- a/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx +++ b/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx @@ -34,7 +34,10 @@ type AltKeyword = Extract< { type: "AlternativeCastChoice" } >["data"]["keyword"]["type"]; -function setSpectacleChoice(keyword: AltKeyword) { +function setSpectacleChoice( + keyword: AltKeyword, + alternativeAdditionalCostDescription: string | null = null, +) { const waitingFor: WaitingFor = { type: "AlternativeCastChoice", data: { @@ -45,6 +48,7 @@ function setSpectacleChoice(keyword: AltKeyword) { normal_cost: { type: "Cost", shards: ["Red"], generic: 3 }, alternative_cost: RED_COST, alternative_additional_cost: null, + alternative_additional_cost_description: alternativeAdditionalCostDescription, }, }; @@ -121,4 +125,11 @@ describe("AlternativeCostModal", () => { ).toBeInTheDocument(); }, ); + + it("renders Emerge's engine-provided sacrifice description", () => { + setSpectacleChoice("Emerge", "an artifact"); + render(); + + expect(screen.getByText(/sacrificing an artifact/i)).toBeInTheDocument(); + }); }); diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 9bdb47a100..524a9c27f5 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1754,7 +1754,8 @@ "emergeEyebrow": "Auftauchen", "emergeNormalLabel": "Normal wirken", "emergeAltLabel": "Mit Auftauchen wirken", - "emergeSubtitle": "Wirke {{name}} normal oder zahle seine Auftauchen-Kosten, indem du eine Kreatur opferst, was die Kosten um den Manawert dieser Kreatur reduziert.", + "emergeSubtitle": "Wirke {{name}} normal oder zahle seine Auftauchen-Kosten, indem du {{sacrifice}} opferst, was die Kosten um den Manawert dieser bleibenden Karte reduziert.", + "emergeFallbackSacrifice": "eine passende bleibende Karte", "impendingEyebrow": "Drohend", "impendingNormalLabel": "Normal wirken", "impendingAltLabel": "Mit Drohend wirken", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index d3285c72ac..dc9903458f 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -1798,7 +1798,8 @@ "emergeEyebrow": "Emerge", "emergeNormalLabel": "Cast Normally", "emergeAltLabel": "Cast with Emerge", - "emergeSubtitle": "Cast {{name}} normally, or pay its Emerge cost by sacrificing a creature, reducing the cost by that creature's mana value.", + "emergeSubtitle": "Cast {{name}} normally, or pay its Emerge cost by sacrificing {{sacrifice}}, reducing the cost by that permanent's mana value.", + "emergeFallbackSacrifice": "a matching permanent", "impendingEyebrow": "Impending", "impendingNormalLabel": "Cast Normally", "impendingAltLabel": "Cast with Impending", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 67ede2a701..94f69ab5db 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1754,7 +1754,8 @@ "emergeEyebrow": "Emerger", "emergeNormalLabel": "Lanzar normalmente", "emergeAltLabel": "Lanzar con Emerger", - "emergeSubtitle": "Lanza {{name}} normalmente, o paga su coste de Emerger sacrificando una criatura, reduciendo el coste en el valor de maná de esa criatura.", + "emergeSubtitle": "Lanza {{name}} normalmente, o paga su coste de Emerger sacrificando {{sacrifice}}, reduciendo el coste en el valor de maná de ese permanente.", + "emergeFallbackSacrifice": "un permanente que cumpla los requisitos", "impendingEyebrow": "Inminencia", "impendingNormalLabel": "Lanzar normalmente", "impendingAltLabel": "Lanzar con Inminencia", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index e0bbb54da9..a9d3968d32 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1754,7 +1754,8 @@ "emergeEyebrow": "Émergence", "emergeNormalLabel": "Lancer normalement", "emergeAltLabel": "Lancer avec Émergence", - "emergeSubtitle": "Lancez {{name}} normalement, ou payez son coût d'Émergence en sacrifiant une créature, ce qui réduit le coût de la valeur de mana de cette créature.", + "emergeSubtitle": "Lancez {{name}} normalement, ou payez son coût d'Émergence en sacrifiant {{sacrifice}}, ce qui réduit le coût de la valeur de mana de ce permanent.", + "emergeFallbackSacrifice": "un permanent correspondant", "impendingEyebrow": "Imminence", "impendingNormalLabel": "Lancer normalement", "impendingAltLabel": "Lancer avec Imminence", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 0b0f514cbd..7fb4bc6515 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1754,7 +1754,8 @@ "emergeEyebrow": "Emergere", "emergeNormalLabel": "Lancia normalmente", "emergeAltLabel": "Lancia con Emergere", - "emergeSubtitle": "Lancia {{name}} normalmente, o paga il suo costo di Emergere sacrificando una creatura, riducendo il costo del valore di mana di quella creatura.", + "emergeSubtitle": "Lancia {{name}} normalmente, o paga il suo costo di Emergere sacrificando {{sacrifice}}, riducendo il costo del valore di mana di quel permanente.", + "emergeFallbackSacrifice": "un permanente corrispondente", "impendingEyebrow": "Incombere", "impendingNormalLabel": "Lancia normalmente", "impendingAltLabel": "Lancia con Incombere", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 4f1cedd660..8d3f3e069c 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1754,7 +1754,8 @@ "emergeEyebrow": "Emerge", "emergeNormalLabel": "Rzuć normalnie", "emergeAltLabel": "Rzuć z Emerge", - "emergeSubtitle": "Rzuć {{name}} normalnie lub zapłać jego koszt Emerge, poświęcając stwora, co zmniejsza koszt o wartość many tego stwora.", + "emergeSubtitle": "Rzuć {{name}} normalnie lub zapłać jego koszt Emerge, poświęcając {{sacrifice}}, co zmniejsza koszt o wartość many tego permanentu.", + "emergeFallbackSacrifice": "pasujący permanent", "impendingEyebrow": "Impending", "impendingNormalLabel": "Rzuć normalnie", "impendingAltLabel": "Rzuć z Impending", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 4e347985e6..e740686c00 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1754,7 +1754,8 @@ "emergeEyebrow": "Emergir", "emergeNormalLabel": "Conjurar Normalmente", "emergeAltLabel": "Conjurar com Emergir", - "emergeSubtitle": "Conjure {{name}} normalmente, ou pague seu custo de Emergir sacrificando uma criatura, reduzindo o custo pelo valor de mana daquela criatura.", + "emergeSubtitle": "Conjure {{name}} normalmente, ou pague seu custo de Emergir sacrificando {{sacrifice}}, reduzindo o custo pelo valor de mana daquele permanente.", + "emergeFallbackSacrifice": "um permanente correspondente", "impendingEyebrow": "Iminente", "impendingNormalLabel": "Conjurar Normalmente", "impendingAltLabel": "Conjurar com Iminente", diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 92c782bde4..fdc889c88d 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -7,7 +7,7 @@ use crate::types::ability::{ ModalSelectionCondition, ObjectScope, PlayerFilter, PlayerScope, ProhibitedActivity, QuantityExpr, QuantityRef, ResolvedAbility, RestrictionExpiry, RestrictionPlayerScope, StaticCondition, StaticDefinition, SubAbilityLink, TapCreaturesRequirement, TargetFilter, - TargetRef, + TargetRef, TypeFilter, }; use crate::types::actions::{AlternativeCastDecision, GameAction}; use crate::types::card::LayoutKind; @@ -2562,6 +2562,41 @@ fn effective_emerge_sacrifice_filter( }) } +/// CR 702.119a-b: Emerge's sacrifice quality is part of the alternative cost, +/// so the engine supplies a display-ready phrase rather than requiring a client +/// to interpret its `TargetFilter`. Complex filters use the localized generic +/// fallback rather than a lossy partial description. +fn emerge_sacrifice_description(sacrifice_filter: &TargetFilter) -> Option { + let TargetFilter::Typed(filter) = sacrifice_filter else { + return None; + }; + if filter.type_filters.len() != 1 + || filter.controller.is_some() + || !filter.properties.is_empty() + { + return None; + } + let subject = match filter.get_primary_type()? { + TypeFilter::Artifact => "artifact", + TypeFilter::Battle => "battle", + TypeFilter::Card => "card", + TypeFilter::Creature => "creature", + TypeFilter::Enchantment => "enchantment", + TypeFilter::Instant => "instant", + TypeFilter::Kindred => "kindred", + TypeFilter::Land => "land", + TypeFilter::Permanent => "permanent", + TypeFilter::Planeswalker => "planeswalker", + TypeFilter::Sorcery => "sorcery", + TypeFilter::Subtype(subtype) => subtype, + TypeFilter::Any | TypeFilter::AnyOf(_) | TypeFilter::Non(_) => "permanent", + }; + let article = matches!(subject.chars().next(), Some('a' | 'e' | 'i' | 'o' | 'u')) + .then_some("an") + .unwrap_or("a"); + Some(format!("{article} {subject}")) +} + /// Fuse-aware sibling of [`effective_spell_keywords`]. `fused` projects a /// pre-payment fused split spell with its COMBINED characteristics (CR 702.102b) /// so `CastWithKeyword`-granted keywords keyed on mana value / colors are granted @@ -11494,6 +11529,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(warp_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } // If only normal is affordable, skip warp — prepare_spell_cast will @@ -11549,6 +11585,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost: offer.normal_cost, alternative_cost: offer.alternative_cost, alternative_additional_cost: offer.alternative_additional_cost, + alternative_additional_cost_description: None, }); } if !eligibility.normal_affordable && eligibility.evoke_affordable { @@ -11603,8 +11640,11 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(emerge_cost_eff), alternative_additional_cost: Some(casting_costs::emerge_sacrifice_cost( - emerge_cost.sacrifice_filter, + emerge_cost.sacrifice_filter.clone(), )), + alternative_additional_cost_description: emerge_sacrifice_description( + &emerge_cost.sacrifice_filter, + ), }); } if !normal_affordable && emerge_affordable { @@ -11656,6 +11696,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(dash_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && dash_affordable { @@ -11711,6 +11752,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(blitz_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && blitz_affordable { @@ -11762,6 +11804,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(spectacle_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && spectacle_affordable { @@ -11819,6 +11862,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(prowl_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && prowl_affordable { @@ -11868,6 +11912,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(overload_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && overload_affordable { @@ -11925,6 +11970,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(mtmte_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && mtmte_affordable { @@ -11978,6 +12024,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(cleave_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && cleave_affordable { @@ -12080,6 +12127,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: bestow_mana_eff, alternative_additional_cost: bestow_non_mana_part, + alternative_additional_cost_description: None, }); } if has_legal_creature_target && bestow_affordable { @@ -12163,6 +12211,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(mutate_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if has_legal_mutate_target && !normal_affordable && mutate_affordable { @@ -12230,6 +12279,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(awaken_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if has_legal_land && !normal_affordable && awaken_affordable { @@ -12277,6 +12327,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(impending_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && impending_affordable { @@ -12324,6 +12375,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(prototype_cost_eff), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } if !normal_affordable && prototype_affordable { @@ -12381,6 +12433,7 @@ pub fn handle_cast_spell_with_payment_mode( normal_cost, alternative_cost: Some(face_down_cost), alternative_additional_cost: None, + alternative_additional_cost_description: None, }); } // Only the face-down {3} is affordable — proceed face down. diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index b7e81db51c..d464cb06ac 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -38024,6 +38024,106 @@ mod alt_cost_reduction_509 { ); } + const CRABOMINATION_ORACLE: &str = "Emerge from artifact {5}{B}{B} (You may cast this spell by sacrificing an artifact and paying the emerge cost reduced by that artifact's mana value.)\nWhen this creature enters, target opponent exiles the top card of their library, a card at random from their graveyard, and a card at random from their hand. You may cast a spell from among cards exiled this way without paying its mana cost."; + + /// CR 702.119b-c: Crabomination's real Oracle text must carry its artifact + /// quality through parsing and into the cast-cost selection pipeline. + #[test] + fn crabomination_real_oracle_casts_by_sacrificing_only_an_artifact() { + use crate::game::scenario::{GameScenario, P0}; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let crabomination = scenario + .add_creature_to_hand_from_oracle(P0, "Crabomination", 5, 5, CRABOMINATION_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black, ManaCostShard::Black], + generic: 4, + }) + .id(); + let artifact = scenario + .add_creature(P0, "Artifact Tribute", 1, 1) + .as_artifact() + .with_mana_cost(ManaCost::generic(5)) + .id(); + let creature = scenario + .add_creature(P0, "Creature Tribute", 1, 1) + .with_mana_cost(ManaCost::generic(1)) + .id(); + + let mut runner = scenario.build(); + add_mana(runner.state_mut(), P0, ManaType::Black, 2); + let card_id = runner.state().objects[&crabomination].card_id; + let mut events = Vec::new(); + let waiting_for = + handle_cast_spell(runner.state_mut(), P0, crabomination, card_id, &mut events) + .expect("Crabomination must enter its Emerge sacrifice payment"); + + match &waiting_for { + WaitingFor::PayCost { + kind: PayCostKind::Sacrifice, + choices, + .. + } => { + assert!(choices.contains(&artifact)); + assert!(!choices.contains(&creature)); + } + other => panic!("expected Crabomination Emerge PayCost(Sacrifice), got {other:?}"), + } + + runner.state_mut().waiting_for = waiting_for; + apply_as_current( + runner.state_mut(), + GameAction::SelectCards { + cards: vec![artifact], + }, + ) + .expect("the artifact sacrifice must complete Crabomination's Emerge cast"); + + assert_eq!(runner.state().objects[&artifact].zone, Zone::Graveyard); + assert_eq!(runner.state().objects[&crabomination].zone, Zone::Stack); + assert_eq!(runner.state().players[P0.0 as usize].mana_pool.total(), 0); + } + + #[test] + fn crabomination_real_oracle_prompt_describes_artifact_sacrifice() { + use crate::game::scenario::{GameScenario, P0}; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let crabomination = scenario + .add_creature_to_hand_from_oracle(P0, "Crabomination", 5, 5, CRABOMINATION_ORACLE) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Black, ManaCostShard::Black], + generic: 4, + }) + .id(); + scenario + .add_creature(P0, "Artifact Tribute", 1, 1) + .as_artifact() + .with_mana_cost(ManaCost::generic(5)); + + let mut runner = scenario.build(); + add_mana(runner.state_mut(), P0, ManaType::Black, 6); + let card_id = runner.state().objects[&crabomination].card_id; + let mut events = Vec::new(); + let waiting_for = + handle_cast_spell(runner.state_mut(), P0, crabomination, card_id, &mut events) + .expect("Crabomination must offer its normal and Emerge casts"); + + match waiting_for { + WaitingFor::AlternativeCastChoice { + keyword: crate::types::game_state::AlternativeCastKeyword::Emerge, + alternative_additional_cost_description, + .. + } => assert_eq!( + alternative_additional_cost_description.as_deref(), + Some("an artifact") + ), + other => panic!("expected Crabomination AlternativeCastChoice(Emerge), got {other:?}"), + } + } + #[test] fn emerge_mana_value_reduction_preserves_colored_pips() { let mut state = setup_game_at_main_phase(); @@ -42792,6 +42892,7 @@ fn bestow_cost_choice_legal_actions_includes_both_paths() { generic: 3, }), alternative_additional_cost: None, + alternative_additional_cost_description: None, payment_mode: CastPaymentMode::Auto, }; let cands = candidate_actions_broad(&state); diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 2212d7691a..c1e366ce5d 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -11474,6 +11474,10 @@ pub enum WaitingFor { /// string; the frontend renders the engine-provided description. #[serde(default)] alternative_additional_cost: Option, + /// Engine-authored display text for an alternative cost's non-mana + /// component when its typed details affect player-facing wording. + #[serde(default)] + alternative_additional_cost_description: Option, }, /// CR 702.140c + CR 730.2a: As a mutating creature spell resolves with a /// legal target, the spell's controller chooses whether the spell is put on diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index 61746efd34..446d169468 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -2403,6 +2403,7 @@ impl FromStr for Keyword { "madness" => return Ok(Keyword::Madness(parse_keyword_mana_cost(p))), "miracle" => return Ok(Keyword::Miracle(parse_keyword_mana_cost(p))), "dash" => return Ok(Keyword::Dash(parse_keyword_mana_cost(p))), + // CR 702.119a: Bare Emerge defaults to sacrificing a creature. "emerge" => { return Ok(Keyword::Emerge(EmergeCost::creature( parse_keyword_mana_cost(p), @@ -3267,6 +3268,8 @@ fn keyword_from_tagged(variant: &str, data: &serde_json::Value) -> Result Ok(Keyword::Madness(mana(data)?)), "Miracle" => Ok(Keyword::Miracle(mana(data)?)), "Dash" => Ok(Keyword::Dash(mana(data)?)), + // CR 702.119a: Historic bare Emerge payloads use only the mana cost, + // which implies the ordinary creature sacrifice filter. "Emerge" => match serde_json::from_value::(data.clone()) { Ok(cost) => Ok(Keyword::Emerge(cost)), Err(_) => Ok(Keyword::Emerge(EmergeCost::creature(mana(data)?))), @@ -4776,6 +4779,29 @@ mod tests { }, } ); + + let legacy_emerge: Keyword = + serde_json::from_str(r#"{"Emerge":{"type":"Cost","shards":["Blue"],"generic":3}}"#) + .expect("legacy Emerge mana payload deserializes"); + assert_eq!( + legacy_emerge, + Keyword::Emerge(EmergeCost::creature(ManaCost::Cost { + shards: vec![crate::types::mana::ManaCostShard::Blue], + generic: 3, + })) + ); + + let legacy_quality_emerge: Keyword = serde_json::from_str( + r#"{"EmergeFromQuality":{"mana_cost":{"type":"Cost","shards":[],"generic":5},"sacrifice_filter":{"type":"Typed","type_filters":["Artifact"],"controller":null,"properties":[]}}}"#, + ) + .expect("legacy EmergeFromQuality payload deserializes"); + assert_eq!( + legacy_quality_emerge, + Keyword::Emerge(EmergeCost::from_quality( + ManaCost::generic(5), + TargetFilter::Typed(TypedFilter::new(TypeFilter::Artifact)), + )) + ); } #[test] diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 7c9f9f9e3f..ca9012a416 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -868,6 +868,7 @@ fn alternative_cast_siblings_use_stable_typed_codes() { normal_cost: ManaCost::NoCost, alternative_cost: Some(ManaCost::NoCost), alternative_additional_cost: None, + alternative_additional_cost_description: None, }; bind(runner.state_mut(), "alternative-cast-codes"); diff --git a/crates/mtgish-import/src/convert/keyword.rs b/crates/mtgish-import/src/convert/keyword.rs index d88b8e4c93..5dbcf2b5e3 100644 --- a/crates/mtgish-import/src/convert/keyword.rs +++ b/crates/mtgish-import/src/convert/keyword.rs @@ -177,6 +177,7 @@ pub fn try_convert(rule: &Rule, path: &str) -> ConvResult> { "Rule::Embalm", path, )?)), + // CR 702.119a: Bare Emerge defaults to sacrificing a creature. Rule::Emerge(c) => Keyword::Emerge(engine::types::keywords::EmergeCost::creature( pure_mana(c, "Rule::Emerge", path)?, )), From 4f4b1f815585aae1c7ec15a4d98ed427e682d127 Mon Sep 17 00:00:00 2001 From: traemyn Date: Sat, 15 Aug 2026 14:28:57 -0500 Subject: [PATCH 4/5] Address remaining Emerge review feedback --- client/src/adapter/types.ts | 21 ++- .../components/modal/AlternativeCostModal.tsx | 56 ++++++- .../__tests__/AlternativeCostModal.test.tsx | 26 ++- client/src/i18n/locales/de/game.json | 14 ++ client/src/i18n/locales/en/game.json | 14 ++ client/src/i18n/locales/es/game.json | 14 ++ client/src/i18n/locales/fr/game.json | 14 ++ client/src/i18n/locales/it/game.json | 14 ++ client/src/i18n/locales/pl/game.json | 14 ++ client/src/i18n/locales/pt/game.json | 14 ++ .../viewmodel/__tests__/keywordProps.test.ts | 11 ++ client/src/viewmodel/keywordProps.ts | 7 + crates/engine/src/game/casting.rs | 156 +++++++++--------- crates/engine/src/game/casting_tests.rs | 6 +- crates/engine/src/types/game_state.rs | 40 ++++- 15 files changed, 327 insertions(+), 94 deletions(-) diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 5f3f49ea08..8495461144 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1620,6 +1620,25 @@ export interface ReplacementCandidateSummary { description: string; } +export type EmergeSacrificeQuality = + | { type: "Artifact" } + | { type: "Battle" } + | { type: "Card" } + | { type: "Creature" } + | { type: "Enchantment" } + | { type: "Instant" } + | { type: "Kindred" } + | { type: "Land" } + | { type: "Permanent" } + | { type: "Planeswalker" } + | { type: "Sorcery" } + | { type: "Subtype"; data: string }; + +export type AlternativeAdditionalCostDescription = { + type: "EmergeSacrifice"; + quality: EmergeSacrificeQuality; +}; + // ── WaitingFor (discriminated union with tag="type", content="data") ───── export type OpeningHandBottomReason = { type: "TinyLeadersMultiCommander" }; @@ -1748,7 +1767,7 @@ export type WaitingFor = // `keyword.type` mirrors engine `AlternativeCastKeyword` (game_state.rs) 1:1. // Keep this union exhaustive with the engine enum so the modal's keyword // switch is type-checked against every variant the engine can emit. - | { type: "AlternativeCastChoice"; data: { player: PlayerId; object_id: ObjectId; card_id: CardId; payment_mode?: CastPaymentMode; keyword: { type: "Warp" } | { type: "Evoke" } | { type: "Emerge" } | { type: "Dash" } | { type: "Blitz" } | { type: "Overload" } | { type: "Bestow" } | { type: "Awaken" } | { type: "Cleave" } | { type: "MoreThanMeetsTheEye" } | { type: "Impending" } | { type: "Prototype" } | { type: "Mutate" } | { type: "Spectacle" } | { type: "Prowl" } | { type: "FaceDown" }; normal_cost: ManaCost; alternative_cost: ManaCost | null; alternative_additional_cost: SerializedAbilityCost | null; alternative_additional_cost_description: string | null } } + | { type: "AlternativeCastChoice"; data: { player: PlayerId; object_id: ObjectId; card_id: CardId; payment_mode?: CastPaymentMode; keyword: { type: "Warp" } | { type: "Evoke" } | { type: "Emerge" } | { type: "Dash" } | { type: "Blitz" } | { type: "Overload" } | { type: "Bestow" } | { type: "Awaken" } | { type: "Cleave" } | { type: "MoreThanMeetsTheEye" } | { type: "Impending" } | { type: "Prototype" } | { type: "Mutate" } | { type: "Spectacle" } | { type: "Prowl" } | { type: "FaceDown" }; normal_cost: ManaCost; alternative_cost: ManaCost | null; alternative_additional_cost: SerializedAbilityCost | null; alternative_additional_cost_description: AlternativeAdditionalCostDescription | null } } // CR 702.140c + CR 730.2a: mutating creature spell resolving with a legal // target — controller chooses to put it on top of or under the target creature. | { type: "MutateMergeChoice"; data: { player: PlayerId; merging_id: ObjectId; target_id: ObjectId } } diff --git a/client/src/components/modal/AlternativeCostModal.tsx b/client/src/components/modal/AlternativeCostModal.tsx index c017b3af2c..3dcc514680 100644 --- a/client/src/components/modal/AlternativeCostModal.tsx +++ b/client/src/components/modal/AlternativeCostModal.tsx @@ -2,6 +2,8 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import type { + AlternativeAdditionalCostDescription, + EmergeSacrificeQuality, GameAction, ManaCost, SerializedAbilityCost, @@ -35,7 +37,7 @@ interface KeywordCopy { function keywordCopy( keyword: Keyword, cardName: string, - alternativeAdditionalCostDescription: string | null, + alternativeAdditionalCostDescription: AlternativeAdditionalCostDescription | null, t: TFunction<"game">, ): KeywordCopy { switch (keyword) { @@ -66,7 +68,9 @@ function keywordCopy( showOracleText: true, subtitle: t("alternativeCost.emergeSubtitle", { name: cardName, - sacrifice: alternativeAdditionalCostDescription ?? t("alternativeCost.emergeFallbackSacrifice"), + sacrifice: alternativeAdditionalCostDescription + ? describeAdditionalCostDescription(alternativeAdditionalCostDescription, t) + : t("alternativeCost.emergeFallbackSacrifice"), }), }; // CR 702.109a: Dash — like Warp, the rider (haste + end-step return to hand) @@ -197,6 +201,48 @@ function keywordCopy( return assertNever(keyword); } +function describeEmergeSacrificeQuality( + quality: EmergeSacrificeQuality, + t: TFunction<"game">, +): string { + switch (quality.type) { + case "Artifact": + return t("alternativeCost.emergeSacrificeQuality.artifact"); + case "Battle": + return t("alternativeCost.emergeSacrificeQuality.battle"); + case "Card": + return t("alternativeCost.emergeSacrificeQuality.card"); + case "Creature": + return t("alternativeCost.emergeSacrificeQuality.creature"); + case "Enchantment": + return t("alternativeCost.emergeSacrificeQuality.enchantment"); + case "Instant": + return t("alternativeCost.emergeSacrificeQuality.instant"); + case "Kindred": + return t("alternativeCost.emergeSacrificeQuality.kindred"); + case "Land": + return t("alternativeCost.emergeSacrificeQuality.land"); + case "Permanent": + return t("alternativeCost.emergeSacrificeQuality.permanent"); + case "Planeswalker": + return t("alternativeCost.emergeSacrificeQuality.planeswalker"); + case "Sorcery": + return t("alternativeCost.emergeSacrificeQuality.sorcery"); + case "Subtype": + return t("alternativeCost.emergeSacrificeQuality.subtype", { subtype: quality.data }); + } +} + +function describeAdditionalCostDescription( + description: AlternativeAdditionalCostDescription, + t: TFunction<"game">, +): string { + switch (description.type) { + case "EmergeSacrifice": + return describeEmergeSacrificeQuality(description.quality, t); + } +} + /** * CR 702.74a + CR 601.2h: Compact display copy for the non-mana portion of * an alternative cost (e.g., Solitude's Evoke "Exile a white card from your @@ -267,7 +313,7 @@ function AlternativeCostContent({ normalCost: ManaCost; alternativeCost: ManaCost | null; alternativeAdditionalCost: SerializedAbilityCost | null; - alternativeAdditionalCostDescription: string | null; + alternativeAdditionalCostDescription: AlternativeAdditionalCostDescription | null; dispatch: (action: GameAction) => Promise; }) { const { t } = useTranslation("game"); @@ -322,7 +368,9 @@ function AlternativeCostContent({ )} {alternativeAdditionalCost && ( - {alternativeAdditionalCostDescription ?? describeAdditionalCost(alternativeAdditionalCost, t)} + {alternativeAdditionalCostDescription + ? describeAdditionalCostDescription(alternativeAdditionalCostDescription, t) + : describeAdditionalCost(alternativeAdditionalCost, t)} )} {copy.altSuffix && ( diff --git a/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx b/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx index b2e984e4dd..52145e5278 100644 --- a/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx +++ b/client/src/components/modal/__tests__/AlternativeCostModal.test.tsx @@ -1,11 +1,13 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { + AlternativeAdditionalCostDescription, GameObject, ManaCost, WaitingFor, } from "../../../adapter/types.ts"; +import { usePreferencesStore } from "../../../stores/preferencesStore.ts"; import { useGameStore } from "../../../stores/gameStore.ts"; import { buildGameObjectWithCoreTypes, buildObjectMap } from "../../../test/factories/gameObjectFactory.ts"; import { buildGameState } from "../../../test/factories/gameStateFactory.ts"; @@ -36,7 +38,7 @@ type AltKeyword = Extract< function setSpectacleChoice( keyword: AltKeyword, - alternativeAdditionalCostDescription: string | null = null, + alternativeAdditionalCostDescription: AlternativeAdditionalCostDescription | null = null, ) { const waitingFor: WaitingFor = { type: "AlternativeCastChoice", @@ -72,10 +74,12 @@ describe("AlternativeCostModal", () => { beforeEach(() => { dispatchMock.mockReset(); dispatchMock.mockResolvedValue(undefined); + usePreferencesStore.setState({ language: "en" }); }); afterEach(() => { cleanup(); + usePreferencesStore.setState({ language: "en" }); }); // Regression for issue #2939: the engine emits `keyword.type === "Spectacle"` @@ -127,9 +131,25 @@ describe("AlternativeCostModal", () => { ); it("renders Emerge's engine-provided sacrifice description", () => { - setSpectacleChoice("Emerge", "an artifact"); + setSpectacleChoice("Emerge", { + type: "EmergeSacrifice", + quality: { type: "Artifact" }, + }); render(); expect(screen.getByText(/sacrificing an artifact/i)).toBeInTheDocument(); }); + + it("localizes Emerge's typed sacrifice quality", async () => { + usePreferencesStore.setState({ language: "es" }); + setSpectacleChoice("Emerge", { + type: "EmergeSacrifice", + quality: { type: "Artifact" }, + }); + render(); + + await waitFor(() => { + expect(screen.getByText(/sacrificando un artefacto/i)).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 524a9c27f5..f7a5770bfb 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -1756,6 +1756,20 @@ "emergeAltLabel": "Mit Auftauchen wirken", "emergeSubtitle": "Wirke {{name}} normal oder zahle seine Auftauchen-Kosten, indem du {{sacrifice}} opferst, was die Kosten um den Manawert dieser bleibenden Karte reduziert.", "emergeFallbackSacrifice": "eine passende bleibende Karte", + "emergeSacrificeQuality": { + "artifact": "ein Artefakt", + "battle": "eine Schlacht", + "card": "eine Karte", + "creature": "eine Kreatur", + "enchantment": "eine Verzauberung", + "instant": "ein Spontanzauber", + "kindred": "ein Stammes-Permanent", + "land": "ein Land", + "permanent": "ein Permanent", + "planeswalker": "ein Planeswalker", + "sorcery": "eine Hexerei", + "subtype": "ein Permanent vom Typ {{subtype}}" + }, "impendingEyebrow": "Drohend", "impendingNormalLabel": "Normal wirken", "impendingAltLabel": "Mit Drohend wirken", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index dc9903458f..230eecffb8 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -1800,6 +1800,20 @@ "emergeAltLabel": "Cast with Emerge", "emergeSubtitle": "Cast {{name}} normally, or pay its Emerge cost by sacrificing {{sacrifice}}, reducing the cost by that permanent's mana value.", "emergeFallbackSacrifice": "a matching permanent", + "emergeSacrificeQuality": { + "artifact": "an artifact", + "battle": "a battle", + "card": "a card", + "creature": "a creature", + "enchantment": "an enchantment", + "instant": "an instant", + "kindred": "a kindred", + "land": "a land", + "permanent": "a permanent", + "planeswalker": "a planeswalker", + "sorcery": "a sorcery", + "subtype": "a permanent of type {{subtype}}" + }, "impendingEyebrow": "Impending", "impendingNormalLabel": "Cast Normally", "impendingAltLabel": "Cast with Impending", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index 94f69ab5db..d122244601 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -1756,6 +1756,20 @@ "emergeAltLabel": "Lanzar con Emerger", "emergeSubtitle": "Lanza {{name}} normalmente, o paga su coste de Emerger sacrificando {{sacrifice}}, reduciendo el coste en el valor de maná de ese permanente.", "emergeFallbackSacrifice": "un permanente que cumpla los requisitos", + "emergeSacrificeQuality": { + "artifact": "un artefacto", + "battle": "una batalla", + "card": "una carta", + "creature": "una criatura", + "enchantment": "un encantamiento", + "instant": "un instantáneo", + "kindred": "un tipo tribal", + "land": "una tierra", + "permanent": "un permanente", + "planeswalker": "un planeswalker", + "sorcery": "un conjuro", + "subtype": "un permanente del tipo {{subtype}}" + }, "impendingEyebrow": "Inminencia", "impendingNormalLabel": "Lanzar normalmente", "impendingAltLabel": "Lanzar con Inminencia", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index a9d3968d32..8043156b0c 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -1756,6 +1756,20 @@ "emergeAltLabel": "Lancer avec Émergence", "emergeSubtitle": "Lancez {{name}} normalement, ou payez son coût d'Émergence en sacrifiant {{sacrifice}}, ce qui réduit le coût de la valeur de mana de ce permanent.", "emergeFallbackSacrifice": "un permanent correspondant", + "emergeSacrificeQuality": { + "artifact": "un artefact", + "battle": "une bataille", + "card": "une carte", + "creature": "une créature", + "enchantment": "un enchantement", + "instant": "un éphémère", + "kindred": "un tribal", + "land": "un terrain", + "permanent": "un permanent", + "planeswalker": "un planeswalker", + "sorcery": "un rituel", + "subtype": "un permanent du type {{subtype}}" + }, "impendingEyebrow": "Imminence", "impendingNormalLabel": "Lancer normalement", "impendingAltLabel": "Lancer avec Imminence", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 7fb4bc6515..c5c28fcdba 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -1756,6 +1756,20 @@ "emergeAltLabel": "Lancia con Emergere", "emergeSubtitle": "Lancia {{name}} normalmente, o paga il suo costo di Emergere sacrificando {{sacrifice}}, riducendo il costo del valore di mana di quel permanente.", "emergeFallbackSacrifice": "un permanente corrispondente", + "emergeSacrificeQuality": { + "artifact": "un artefatto", + "battle": "una battaglia", + "card": "una carta", + "creature": "una creatura", + "enchantment": "un incantesimo", + "instant": "un istantaneo", + "kindred": "un tribale", + "land": "una terra", + "permanent": "un permanente", + "planeswalker": "un planeswalker", + "sorcery": "una stregoneria", + "subtype": "un permanente di tipo {{subtype}}" + }, "impendingEyebrow": "Incombere", "impendingNormalLabel": "Lancia normalmente", "impendingAltLabel": "Lancia con Incombere", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 8d3f3e069c..5d66a0f0d2 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -1756,6 +1756,20 @@ "emergeAltLabel": "Rzuć z Emerge", "emergeSubtitle": "Rzuć {{name}} normalnie lub zapłać jego koszt Emerge, poświęcając {{sacrifice}}, co zmniejsza koszt o wartość many tego permanentu.", "emergeFallbackSacrifice": "pasujący permanent", + "emergeSacrificeQuality": { + "artifact": "artefakt", + "battle": "bitwę", + "card": "kartę", + "creature": "stwora", + "enchantment": "urok", + "instant": "sztuczkę", + "kindred": "permanent typowy", + "land": "ląd", + "permanent": "permanent", + "planeswalker": "wędrowca", + "sorcery": "obrzęd", + "subtype": "permanent typu {{subtype}}" + }, "impendingEyebrow": "Impending", "impendingNormalLabel": "Rzuć normalnie", "impendingAltLabel": "Rzuć z Impending", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index e740686c00..dc97cf31cf 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -1756,6 +1756,20 @@ "emergeAltLabel": "Conjurar com Emergir", "emergeSubtitle": "Conjure {{name}} normalmente, ou pague seu custo de Emergir sacrificando {{sacrifice}}, reduzindo o custo pelo valor de mana daquele permanente.", "emergeFallbackSacrifice": "um permanente correspondente", + "emergeSacrificeQuality": { + "artifact": "um artefato", + "battle": "uma batalha", + "card": "uma carta", + "creature": "uma criatura", + "enchantment": "um encantamento", + "instant": "uma mágica instantânea", + "kindred": "um tipo tribal", + "land": "um terreno", + "permanent": "uma permanente", + "planeswalker": "um planeswalker", + "sorcery": "uma mágica", + "subtype": "uma permanente do tipo {{subtype}}" + }, "impendingEyebrow": "Iminente", "impendingNormalLabel": "Conjurar Normalmente", "impendingAltLabel": "Conjurar com Iminente", diff --git a/client/src/viewmodel/__tests__/keywordProps.test.ts b/client/src/viewmodel/__tests__/keywordProps.test.ts index da12bc080f..5b634ee42a 100644 --- a/client/src/viewmodel/__tests__/keywordProps.test.ts +++ b/client/src/viewmodel/__tests__/keywordProps.test.ts @@ -60,6 +60,17 @@ describe("getKeywordDetail", () => { expect(getKeywordDetail({ Flashback: "SelfManaCost" })).toBe("its mana cost"); }); + it("formats the mana cost nested in EmergeCost", () => { + expect( + getKeywordDetail({ + Emerge: { + mana_cost: { Cost: { shards: ["Black", "Black"], generic: 5 } }, + sacrifice_filter: { type: "Typed", type_filters: ["Artifact"] }, + }, + }), + ).toBe("{5}{B}{B}"); + }); + it("formats u32 params", () => { expect(getKeywordDetail({ Dredge: 3 })).toBe("3"); expect(getKeywordDetail({ Annihilator: 2 })).toBe("2"); diff --git a/client/src/viewmodel/keywordProps.ts b/client/src/viewmodel/keywordProps.ts index 8cde76d047..e8b1c7d33d 100644 --- a/client/src/viewmodel/keywordProps.ts +++ b/client/src/viewmodel/keywordProps.ts @@ -370,6 +370,13 @@ export function getKeywordDetail(kw: Keyword): string | null { const key = Object.keys(kw)[0]; const val = kw[key]; + if (key === "Emerge") { + const manaCost = val && typeof val === "object" && "mana_cost" in val + ? val.mana_cost + : val; + return formatKeywordManaCost(manaCost); + } + if (MANA_COST_KEYWORDS.has(key)) return formatKeywordManaCost(val); if (U32_KEYWORDS.has(key)) return String(val); diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index fdc889c88d..8596fb81dc 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -13,8 +13,9 @@ use crate::types::actions::{AlternativeCastDecision, GameAction}; use crate::types::card::LayoutKind; use crate::types::events::{ActivatedAbilityKind, GameEvent}; use crate::types::game_state::{ - ActivationResidual, ActivationTargetSelection, CastOfferKind, CastPaymentMode, - CastingPermissionIndex, CastingVariant, CastingVariantChoiceOption, ConvokeMode, CostResume, + ActivationResidual, ActivationTargetSelection, AlternativeAdditionalCostDescription, + CastOfferKind, CastPaymentMode, CastingPermissionIndex, CastingVariant, + CastingVariantChoiceOption, ConvokeMode, CostResume, DistributionUnit, EmergeSacrificeQuality, GameState, ManaAbilityCostParent, ManaAbilityResume, ManaChoice, ManaChoiceContext, ManaChoicePrompt, NextSpellModifier, PayCostKind, PendingCast, PendingCostMoveResume, SneakPlacement, SpellCostSource, StackEntry, StackEntryKind, TargetEffectDetail, @@ -2547,26 +2548,28 @@ pub(crate) fn effective_spell_keywords( effective_spell_keywords_for(state, caster, object_id, false) } -/// CR 702.119a-b: The active Emerge keyword supplies the permanent quality for -/// its required sacrifice cost. -fn effective_emerge_sacrifice_filter( +/// CR 702.119a-b: The active Emerge keyword supplies both the mana cost and +/// permanent quality for its required sacrifice cost. +fn effective_emerge_cost( state: &GameState, caster: PlayerId, object_id: ObjectId, -) -> Option { +) -> Option { effective_spell_keywords(state, caster, object_id) .into_iter() .find_map(|keyword| match keyword { - Keyword::Emerge(cost) => Some(cost.sacrifice_filter), + Keyword::Emerge(cost) => Some(cost), _ => None, }) } -/// CR 702.119a-b: Emerge's sacrifice quality is part of the alternative cost, -/// so the engine supplies a display-ready phrase rather than requiring a client -/// to interpret its `TargetFilter`. Complex filters use the localized generic +/// CR 702.119b: Emerge's sacrifice quality is part of the alternative cost, so +/// the engine supplies a typed descriptor rather than requiring a client to +/// interpret its `TargetFilter`. Complex filters use the localized generic /// fallback rather than a lossy partial description. -fn emerge_sacrifice_description(sacrifice_filter: &TargetFilter) -> Option { +fn emerge_sacrifice_description( + sacrifice_filter: &TargetFilter, +) -> Option { let TargetFilter::Typed(filter) = sacrifice_filter else { return None; }; @@ -2576,25 +2579,60 @@ fn emerge_sacrifice_description(sacrifice_filter: &TargetFilter) -> Option "artifact", - TypeFilter::Battle => "battle", - TypeFilter::Card => "card", - TypeFilter::Creature => "creature", - TypeFilter::Enchantment => "enchantment", - TypeFilter::Instant => "instant", - TypeFilter::Kindred => "kindred", - TypeFilter::Land => "land", - TypeFilter::Permanent => "permanent", - TypeFilter::Planeswalker => "planeswalker", - TypeFilter::Sorcery => "sorcery", - TypeFilter::Subtype(subtype) => subtype, - TypeFilter::Any | TypeFilter::AnyOf(_) | TypeFilter::Non(_) => "permanent", + let quality = match filter.type_filters.first()? { + TypeFilter::Artifact => EmergeSacrificeQuality::Artifact, + TypeFilter::Battle => EmergeSacrificeQuality::Battle, + TypeFilter::Card => EmergeSacrificeQuality::Card, + TypeFilter::Creature => EmergeSacrificeQuality::Creature, + TypeFilter::Enchantment => EmergeSacrificeQuality::Enchantment, + TypeFilter::Instant => EmergeSacrificeQuality::Instant, + TypeFilter::Kindred => EmergeSacrificeQuality::Kindred, + TypeFilter::Land => EmergeSacrificeQuality::Land, + TypeFilter::Permanent => EmergeSacrificeQuality::Permanent, + TypeFilter::Planeswalker => EmergeSacrificeQuality::Planeswalker, + TypeFilter::Sorcery => EmergeSacrificeQuality::Sorcery, + TypeFilter::Subtype(subtype) => EmergeSacrificeQuality::Subtype(subtype.clone()), + TypeFilter::Any | TypeFilter::AnyOf(_) | TypeFilter::Non(_) => return None, }; - let article = matches!(subject.chars().next(), Some('a' | 'e' | 'i' | 'o' | 'u')) - .then_some("an") - .unwrap_or("a"); - Some(format!("{article} {subject}")) + Some(AlternativeAdditionalCostDescription::EmergeSacrifice { quality }) +} + +/// CR 702.119c + CR 601.2b/h: Declare Emerge's required sacrifice before +/// targets and mana payment, using the same effective keyword snapshot as the +/// alternative-cost offer and mana-cost substitution paths. +fn begin_emerge_cost_before_targets( + state: &mut GameState, + player: PlayerId, + prepared: &PreparedSpellCast, + resolved: ResolvedAbility, + distribute: Option, + events: &mut Vec, +) -> Result { + let sacrifice_filter = effective_emerge_cost(state, player, prepared.object_id) + .ok_or_else(|| { + EngineError::ActionNotAllowed( + "Emerge casting variant requires an effective Emerge keyword".to_string(), + ) + })? + .sacrifice_filter; + casting_costs::begin_required_cost_before_targets( + state, + player, + prepared.object_id, + prepared.card_id, + resolved, + prepared.mana_cost.clone(), + Some(prepared.base_mana_cost.clone()), + casting_costs::emerge_sacrifice_cost(sacrifice_filter), + SpellCostSource::Emerge, + prepared.casting_variant, + prepared.casting_permission_index, + prepared.cast_timing_permission, + distribute, + prepared.origin_zone, + prepared.payment_mode, + events, + ) } /// Fuse-aware sibling of [`effective_spell_keywords`]. `fused` projects a @@ -6592,16 +6630,10 @@ fn prepare_spell_cast_with_variant_override_inner( // (CR 702.119c, CR 601.2h). // CR 702.102b: GUARDED — arm requires `casting_variant == Emerge`; Fuse never // equals it, so this read is unreachable for a fused split cast. - let emerge_cost = if casting_variant == CastingVariant::Emerge { - effective_spell_keywords(state, player, object_id) - .iter() - .find_map(|k| match k { - crate::types::keywords::Keyword::Emerge(cost) => Some(cost.mana_cost.clone()), - _ => None, - }) - } else { - None - }; + let emerge_cost = (casting_variant == CastingVariant::Emerge) + .then(|| effective_emerge_cost(state, player, object_id)) + .flatten() + .map(|cost| cost.mana_cost); // CR 702.103a + CR 118.9: When the caller explicitly opted into Bestow (via // `variant_override = Some(CastingVariant::Bestow)`), substitute the bestow // mana sub-cost taken from the object's `Keyword::Bestow(cost)` payload. @@ -11607,13 +11639,7 @@ pub fn handle_cast_spell_with_payment_mode( // permanent's mana value is subtracted. if let Some(obj) = state.objects.get(&object_id) { if obj.zone == Zone::Hand { - if let Some(emerge_cost) = effective_spell_keywords(state, player, object_id) - .into_iter() - .find_map(|k| match k { - crate::types::keywords::Keyword::Emerge(cost) => Some(cost), - _ => None, - }) - { + if let Some(emerge_cost) = effective_emerge_cost(state, player, object_id) { let (normal_cost, normal_affordable) = normal_cast_choice_cost_and_affordability(state, player, object_id, obj); let emerge_cost_eff = apply_cost_modifiers_to_base( @@ -12927,27 +12953,15 @@ fn continue_with_prepared( // then sacrificing it as that cost is paid. Route this before any target // selection so the required sacrifice is declared on the CR 601.2b axis. if prepared.casting_variant == CastingVariant::Emerge { - let sacrifice_filter = effective_emerge_sacrifice_filter(state, player, prepared.object_id) - .expect("Emerge casting variant requires an effective Emerge keyword"); - return casting_costs::begin_required_cost_before_targets( + return begin_emerge_cost_before_targets( state, player, - prepared.object_id, - prepared.card_id, + &prepared, resolved, - prepared.mana_cost, - Some(prepared.base_mana_cost.clone()), - casting_costs::emerge_sacrifice_cost(sacrifice_filter), - SpellCostSource::Emerge, - prepared.casting_variant, - prepared.casting_permission_index, - prepared.cast_timing_permission, prepared .ability_def .as_ref() .and_then(|a| a.distribute.clone()), - prepared.origin_zone, - prepared.payment_mode, events, ); } @@ -13439,24 +13453,12 @@ fn continue_with_no_ability( player, ); if prepared.casting_variant == CastingVariant::Emerge { - let sacrifice_filter = effective_emerge_sacrifice_filter(state, player, prepared.object_id) - .expect("Emerge casting variant requires an effective Emerge keyword"); - return casting_costs::begin_required_cost_before_targets( + return begin_emerge_cost_before_targets( state, player, - prepared.object_id, - prepared.card_id, + &prepared, placeholder, - prepared.mana_cost, - Some(prepared.base_mana_cost.clone()), - casting_costs::emerge_sacrifice_cost(sacrifice_filter), - SpellCostSource::Emerge, - prepared.casting_variant, - prepared.casting_permission_index, - prepared.cast_timing_permission, None, - prepared.origin_zone, - prepared.payment_mode, events, ); } @@ -14328,14 +14330,14 @@ fn can_cast_prepared_now_with_probe( if prepared.casting_variant == CastingVariant::Emerge { return (prepared.modal.is_some() || spell_has_legal_targets_with_probe(state, obj.id, player, probe)) - && effective_emerge_sacrifice_filter(state, player, prepared.object_id).is_some_and( - |sacrifice_filter| { + && effective_emerge_cost(state, player, prepared.object_id).is_some_and( + |emerge_cost| { casting_costs::can_pay_emerge_cost( state, player, prepared.object_id, &prepared.mana_cost, - &sacrifice_filter, + &emerge_cost.sacrifice_filter, ) }, ); diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index d464cb06ac..325b829eeb 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -38117,8 +38117,10 @@ mod alt_cost_reduction_509 { alternative_additional_cost_description, .. } => assert_eq!( - alternative_additional_cost_description.as_deref(), - Some("an artifact") + alternative_additional_cost_description, + Some(crate::types::game_state::AlternativeAdditionalCostDescription::EmergeSacrifice { + quality: crate::types::game_state::EmergeSacrificeQuality::Artifact, + }) ), other => panic!("expected Crabomination AlternativeCastChoice(Emerge), got {other:?}"), } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index c1e366ce5d..3750e04bbd 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -7598,6 +7598,30 @@ pub struct PileResult { /// /// Adding a new alternative-cost keyword (e.g., Madness CR 702.35a, Spectacle /// CR 702.137a) is a compile error at every dispatch site until handled. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AlternativeAdditionalCostDescription { + /// CR 702.119b: The quality named by Emerge from [quality]. + EmergeSacrifice { quality: EmergeSacrificeQuality }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum EmergeSacrificeQuality { + Artifact, + Battle, + Card, + Creature, + Enchantment, + Instant, + Kindred, + Land, + Permanent, + Planeswalker, + Sorcery, + Subtype(String), +} + #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] #[serde(tag = "type")] pub enum AlternativeCastKeyword { @@ -7606,8 +7630,9 @@ pub enum AlternativeCastKeyword { /// CR 702.74a: ETB + sacrifice trigger fires when the resolving permanent /// was cast for its evoke cost (CR 702.74b). Evoke, - /// CR 702.119a-c: Emerge alternative cost requires sacrificing a creature - /// while casting and reduces the emerge cost by that creature's mana value. + /// CR 702.119a-c: Emerge alternative cost requires sacrificing the specified + /// permanent quality while casting and reduces the emerge cost by that + /// permanent's mana value. Emerge, /// CR 702.109a: Cast for the dash cost — the resolving permanent gains haste /// and is returned to its owner's hand at the next end step. @@ -11470,14 +11495,15 @@ pub enum WaitingFor { /// the alternative cost (e.g., `AbilityCost::Exile { count, zone, /// filter }` for the MH2 Evoke Incarnations). `None` when the /// alternative cost is pure mana (Warp, Lorwyn Evoke, Overload, - /// Bestow, mana-only Flashback). Engine owns the derived display - /// string; the frontend renders the engine-provided description. + /// Bestow, mana-only Flashback). The engine owns the typed display + /// payload; the frontend localizes and renders the descriptor. #[serde(default)] alternative_additional_cost: Option, - /// Engine-authored display text for an alternative cost's non-mana - /// component when its typed details affect player-facing wording. + /// Engine-authored typed display descriptor for an alternative cost's + /// non-mana component when its semantic details affect player-facing + /// wording. The frontend localizes this descriptor. #[serde(default)] - alternative_additional_cost_description: Option, + alternative_additional_cost_description: Option, }, /// CR 702.140c + CR 730.2a: As a mutating creature spell resolves with a /// legal target, the spell's controller chooses whether the spell is put on From 5c99e6f255d1da3cddb5502c08d2f5c3d49781e8 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 14:04:53 -0700 Subject: [PATCH 5/5] fix(PR-7410): bump wire protocol for typed Emerge descriptor --- client/src/network/__tests__/protocol.test.ts | 18 +++++++++--------- client/src/network/protocol.ts | 5 ++++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/client/src/network/__tests__/protocol.test.ts b/client/src/network/__tests__/protocol.test.ts index 6e37b10492..58104761bf 100644 --- a/client/src/network/__tests__/protocol.test.ts +++ b/client/src/network/__tests__/protocol.test.ts @@ -36,8 +36,8 @@ const viewerInteractionWithProducedMana = { } as never; describe("encodeWireMessage / decodeWireMessage", () => { - it("pins the P2P wire protocol to v22", () => { - expect(WIRE_PROTOCOL_VERSION).toBe(22); + it("pins the P2P wire protocol to v23", () => { + expect(WIRE_PROTOCOL_VERSION).toBe(23); }); it("defaults shortcut actions for a legacy payload created before the additive field", () => { @@ -234,15 +234,15 @@ describe("encodeWireMessage / decodeWireMessage", () => { // and nothing about the version. Both halves here stamp LITERALS — a frame // built from WIRE_PROTOCOL_VERSION cannot tell a bumped client from an // unbumped one, which is why every other handshake fixture in the suite is - // useless as an instrument for a bump. Revert 22 → 21 and BOTH halves red: - // the v21 frame stops being refused, and the v22 frame stops being admitted. - // The admitting half is the reach-guard: without it "refuses v21" is also + // useless as an instrument for a bump. Revert 23 → 22 and BOTH halves red: + // the v22 frame stops being refused, and the v23 frame stops being admitted. + // The admitting half is the reach-guard: without it "refuses v22" is also // satisfied by a client that refuses everything. - it("refuses the previous wire protocol (v21) and admits its own (v22)", () => { - expect(() => validateMessage(setupFrameAt(21))).toThrow(/Wire protocol mismatch/); - expect(validateMessage(setupFrameAt(22))).toMatchObject({ + it("refuses the previous wire protocol (v22) and admits its own (v23)", () => { + expect(() => validateMessage(setupFrameAt(22))).toThrow(/Wire protocol mismatch/); + expect(validateMessage(setupFrameAt(23))).toMatchObject({ type: "game_setup", - wireProtocolVersion: 22, + wireProtocolVersion: 23, }); }); diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 2febc35eca..c3b39b4cd8 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -80,6 +80,9 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * of silently corrupting state. * * Bumps to date: + * 23 — WaitingFor::AlternativeCastChoice.alternative_additional_cost_description + * changed from a string to a typed Emerge-sacrifice descriptor. Older + * clients would receive an object where their modal expects display text. * 22 — LegalActionsWire.viewerInteraction carries attachmentViews: the engine's * membership list for each host's attachment fan. It parses on a v21 peer * as an empty map, so the loss is silent — a guest paired with a v21 host @@ -120,7 +123,7 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * sub-phase on WaitingFor::MulliganDecision; the MulliganBottomCards * variant was removed */ -export const WIRE_PROTOCOL_VERSION = 22 as const; +export const WIRE_PROTOCOL_VERSION = 23 as const; export type P2PMessage = P2PAuthorityWire & ( | { type: "guest_deck"; deckData: unknown; displayName?: string; reservationToken?: string }