Skip to main content

manabrew_engine/ability/
activated.rs

1use serde::{Deserialize, Serialize};
2
3use crate::ability::api_type::ApiType;
4use crate::ability::ProducedMana;
5use crate::cost::{parse_cost, Cost};
6use crate::parsing::keys;
7use crate::parsing::{parse_semantic_param_value, Params, SemanticParamValue};
8use forge_foundation::ZoneType;
9
10/// A parsed activated ability from a card's A: line.
11/// Mirrors Java's SpellAbility with AB$ prefix.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ActivatedAbility {
14    /// Index of this ability in the card's abilities list.
15    pub ability_index: usize,
16    /// The parsed cost to activate.
17    pub cost: Cost,
18    /// The full raw ability text (for effect resolution reuse).
19    pub ability_text: String,
20    /// Whether this is a mana ability (resolves without using the stack).
21    pub is_mana_ability: bool,
22    /// Parsed AB$ API name.
23    #[serde(default)]
24    pub ability_kind: String,
25    /// Parsed AB$ API type when recognized by the engine.
26    #[serde(default)]
27    pub ability_api: Option<ApiType>,
28    /// Parsed ActivationZone$ override. Activated abilities default to battlefield.
29    #[serde(default)]
30    pub activation_zone: Option<ZoneType>,
31    /// Lowered Produced$ mana expression.
32    #[serde(default)]
33    pub produced_ir: Option<ProducedMana>,
34    /// Parsed RestrictValid$ value for mana abilities.
35    #[serde(default)]
36    pub restrict_valid: Option<String>,
37    /// Parsed Amount$ value for mana abilities.
38    #[serde(default)]
39    pub amount: Option<String>,
40    /// Parsed SpellDescription$ value for UI prompts.
41    #[serde(default)]
42    pub spell_description: Option<String>,
43    /// Parsed PrecostDesc$ value for UI prompts.
44    #[serde(default)]
45    pub precost_desc: Option<String>,
46    /// Parsed Description$ value for UI prompts.
47    #[serde(default)]
48    pub description: Option<String>,
49    /// Parsed AddNoCounter$ flag for produced mana metadata.
50    #[serde(default)]
51    pub adds_no_counter: bool,
52    /// Parsed AddsKeywords$ value for produced mana metadata.
53    #[serde(default)]
54    pub adds_keywords: Option<String>,
55    /// Parsed AddsKeywordsValid$ value for produced mana metadata.
56    #[serde(default)]
57    pub adds_keywords_valid: Option<String>,
58    /// Parsed AddsCounters$ value for produced mana metadata.
59    #[serde(default)]
60    pub adds_counters: Option<String>,
61    /// Parsed AddsCountersValid$ value for produced mana metadata.
62    #[serde(default)]
63    pub adds_counters_valid: Option<String>,
64    /// Parsed TriggersWhenSpent$ value for produced mana metadata.
65    #[serde(default)]
66    pub triggers_when_spent: Option<String>,
67    /// Parsed SubAbility$ SVar name.
68    #[serde(default)]
69    pub sub_ability: Option<String>,
70    /// Lowercased SpellDescription$ for activation keyword routing.
71    #[serde(default)]
72    pub spell_description_lower: String,
73    /// Parsed GameActivationLimit$ for cheap action-space gating.
74    #[serde(default)]
75    pub game_activation_limit: Option<u32>,
76    /// Whether this ability has PowerUp$ True.
77    #[serde(default)]
78    pub power_up: bool,
79    /// Whether this ability has SorcerySpeed$ True.
80    #[serde(default)]
81    pub sorcery_speed: bool,
82    /// Whether this is the synthetic Room UnlockDoor ability.
83    #[serde(default)]
84    pub is_unlock_door: bool,
85    /// Whether this is a ManaReflected ability.
86    #[serde(default)]
87    pub is_mana_reflected: bool,
88    /// Parsed pipe-delimited parameters.
89    pub params: Params,
90}
91
92impl ActivatedAbility {
93    pub fn display_description(&self, card_name: &str) -> String {
94        self.spell_description
95            .as_deref()
96            .or(self.precost_desc.as_deref())
97            .or(self.description.as_deref())
98            .unwrap_or(&self.ability_text)
99            .replace("CARDNAME", card_name)
100    }
101
102    pub fn cost_string(&self) -> Option<String> {
103        let s = self.cost.to_simple_string();
104        (!s.is_empty()).then_some(s)
105    }
106
107    pub fn produced_color_symbols(&self) -> Vec<String> {
108        use crate::ability::{ProducedMana, ProducedManaCombo};
109        match &self.produced_ir {
110            Some(ProducedMana::Fixed(v))
111            | Some(ProducedMana::Combo(ProducedManaCombo::Colors(v))) => v.clone(),
112            _ => Vec::new(),
113        }
114    }
115}
116
117/// Parse an ability string into an ActivatedAbility, if it's an AB$ line.
118/// Returns None for SP$/DB$/trigger lines.
119///
120/// Example AB$ lines:
121/// - `"AB$ Mana | Cost$ T | Produced$ G | SpellDescription$ Add {G}."`
122/// - `"AB$ DealDamage | Cost$ T | ValidTgts$ Any | NumDmg$ 1 | ..."`
123/// - `"AB$ ChangeZone | Cost$ Sac<1/CARDNAME> | Origin$ Library | ..."`
124pub fn parse_activated_ability(raw: &str, index: usize) -> Option<ActivatedAbility> {
125    let params = Params::from_raw(raw);
126
127    // Check if any key contains "AB" — the main key is something like "AB" with value "Mana"
128    // In practice the format is "AB$ Mana | Cost$ T | ..."
129    // After Params::from_raw, we get {"AB": "Mana", "Cost": "T", ...}
130    let has_ab = params.has(keys::AB);
131    if !has_ab {
132        return None;
133    }
134
135    // Extract cost
136    let cost_str = params.get(keys::COST).unwrap_or("");
137    let cost = parse_cost(cost_str);
138
139    // Determine if this is a mana ability:
140    // - Effect type is "Mana"
141    // - No ValidTgts$ (targeting makes it non-mana)
142    // - No loyalty cost
143    let ab_type = params.get(keys::AB).unwrap_or("");
144    let has_targets = params.has(keys::VALID_TGTS);
145    let is_planeswalker_ability = params.is_true(keys::PLANESWALKER);
146    let is_mana_ability = (ab_type.eq_ignore_ascii_case("Mana")
147        || ab_type.eq_ignore_ascii_case("ManaReflected"))
148        && !has_targets
149        && !is_planeswalker_ability;
150    let activation_zone = params
151        .get(keys::ACTIVATION_ZONE)
152        .and_then(ZoneType::from_str_compat);
153    let game_activation_limit = params
154        .get(keys::GAME_ACTIVATION_LIMIT)
155        .and_then(|v| v.parse::<u32>().ok());
156    let power_up = params.is_true(keys::POWER_UP);
157    let sorcery_speed = params.is_true(keys::SORCERY_SPEED);
158    let is_unlock_door = ab_type.eq_ignore_ascii_case("UnlockDoor");
159    let is_mana_reflected = ab_type.eq_ignore_ascii_case("ManaReflected");
160    let ability_kind = ab_type.to_string();
161    let ability_api = ApiType::smart_value_of(ab_type);
162    let produced_ir = params.get(keys::PRODUCED).and_then(|raw| {
163        match parse_semantic_param_value(keys::PRODUCED, raw) {
164            SemanticParamValue::ProducedMana(produced) => {
165                Some(ProducedMana::from_semantic(&produced))
166            }
167            _ => None,
168        }
169    });
170    let restrict_valid = params.get(keys::RESTRICT_VALID).map(str::to_string);
171    let amount = params.get(keys::AMOUNT).map(str::to_string);
172    let spell_description = params.get(keys::SPELL_DESCRIPTION).map(str::to_string);
173    let precost_desc = params.get(keys::PRECOST_DESC).map(str::to_string);
174    let description = params.get(keys::DESCRIPTION).map(str::to_string);
175    let adds_no_counter = params.is_true(keys::ADDS_NO_COUNTER);
176    let adds_keywords = params.get(keys::ADDS_KEYWORDS).map(str::to_string);
177    let adds_keywords_valid = params.get(keys::ADDS_KEYWORDS_VALID).map(str::to_string);
178    let adds_counters = params.get(keys::ADDS_COUNTERS).map(str::to_string);
179    let adds_counters_valid = params.get(keys::ADDS_COUNTERS_VALID).map(str::to_string);
180    let triggers_when_spent = params.get(keys::TRIGGERS_WHEN_SPENT).map(str::to_string);
181    let sub_ability = params.get(keys::SUB_ABILITY).map(str::to_string);
182    let spell_description_lower = params
183        .get(keys::SPELL_DESCRIPTION)
184        .unwrap_or("")
185        .to_ascii_lowercase();
186
187    Some(ActivatedAbility {
188        ability_index: index,
189        cost,
190        ability_text: raw.to_string(),
191        is_mana_ability,
192        ability_kind,
193        ability_api,
194        activation_zone,
195        produced_ir,
196        restrict_valid,
197        amount,
198        spell_description,
199        precost_desc,
200        description,
201        adds_no_counter,
202        adds_keywords,
203        adds_keywords_valid,
204        adds_counters,
205        adds_counters_valid,
206        triggers_when_spent,
207        sub_ability,
208        spell_description_lower,
209        game_activation_limit,
210        power_up,
211        sorcery_speed,
212        is_unlock_door,
213        is_mana_reflected,
214        params,
215    })
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn parse_llanowar_elves_mana_ability() {
224        let raw = "AB$ Mana | Cost$ T | Produced$ G | SpellDescription$ Add {G}.";
225        let ab = parse_activated_ability(raw, 0).unwrap();
226        assert!(ab.is_mana_ability);
227        assert!(ab.cost.has_tap);
228        assert_eq!(
229            ab.produced_ir
230                .as_ref()
231                .map(ProducedMana::as_script_text)
232                .as_deref(),
233            Some("G")
234        );
235    }
236
237    #[test]
238    fn parse_planeswalker_mana_ability_uses_stack() {
239        let raw = "AB$ Mana | Cost$ AddCounter<0/LOYALTY> | Planeswalker$ True | Produced$ C | Amount$ 3 | SpellDescription$ Add {C}{C}{C}.";
240        let ab = parse_activated_ability(raw, 0).unwrap();
241        assert!(!ab.is_mana_ability);
242    }
243
244    #[test]
245    fn parse_prodigal_sorcerer_non_mana() {
246        let raw = "AB$ DealDamage | Cost$ T | ValidTgts$ Any | NumDmg$ 1 | SpellDescription$ CARDNAME deals 1 damage to any target.";
247        let ab = parse_activated_ability(raw, 0).unwrap();
248        let ActivatedAbility {
249            params: raw_params, ..
250        } = &ab;
251        assert!(!ab.is_mana_ability);
252        assert!(ab.cost.has_tap);
253        assert_eq!(raw_params.get(keys::NUM_DMG).unwrap(), "1");
254    }
255
256    #[test]
257    fn parse_sakura_tribe_elder_sacrifice() {
258        let raw = "AB$ ChangeZone | Cost$ Sac<1/CARDNAME> | Origin$ Library | Destination$ Battlefield | Tapped$ True | ChangeType$ Land.Basic | SpellDescription$ Search for a basic land.";
259        let ab = parse_activated_ability(raw, 0).unwrap();
260        let ActivatedAbility {
261            params: raw_params, ..
262        } = &ab;
263        assert!(!ab.is_mana_ability);
264        assert!(!ab.cost.has_tap);
265        assert_eq!(raw_params.get(keys::ORIGIN).unwrap(), "Library");
266        assert_eq!(raw_params.get(keys::DESTINATION).unwrap(), "Battlefield");
267    }
268
269    #[test]
270    fn sp_line_returns_none() {
271        let raw = "SP$ DealDamage | ValidTgts$ Any | NumDmg$ 3 | SpellDescription$ test";
272        assert!(parse_activated_ability(raw, 0).is_none());
273    }
274
275    #[test]
276    fn db_line_returns_none() {
277        let raw = "DB$ Draw | Defined$ You | NumCards$ 2";
278        assert!(parse_activated_ability(raw, 0).is_none());
279    }
280}