Skip to main content

manabrew_engine/staticability/
static_ability_alternative_cost.rs

1use forge_foundation::ZoneType;
2
3use crate::card::{valid_filter, Card};
4use crate::cost::{parse_cost, Cost};
5use crate::game::GameState;
6use crate::ids::PlayerId;
7use crate::spellability::{matches_valid_sa, SpellAbility};
8use crate::staticability::StaticMode;
9
10/// Descriptor for an alternative cost produced by a static ability.
11#[derive(Debug, Clone)]
12pub struct AlternativeCostEntry {
13    /// The parsed alternative cost (Cost$ param with ConvertedManaCost replaced).
14    pub cost: Cost,
15    /// The raw cost template string (after ConvertedManaCost substitution).
16    pub cost_string: String,
17    /// Whether the original SA is an ability (not a spell).
18    pub is_ability: bool,
19    /// Whether the original SA is a spell.
20    pub is_spell: bool,
21    /// Propagated XAlternative param, if any.
22    pub x_alternative: Option<String>,
23    /// Propagated Announce param, if any.
24    pub announce: Option<String>,
25    /// Propagated ManaRestriction param, if any.
26    pub mana_restriction: Option<String>,
27    /// Zone restriction from the static ability's ActiveZones (single zone case).
28    pub zone_restriction: Option<ZoneType>,
29    /// StackDescription override, if any.
30    pub stack_description: Option<String>,
31    /// CostDesc override, if any.
32    pub cost_desc: Option<String>,
33    /// Description text for the alternative cost.
34    pub description: String,
35    /// Named override — custom card alternative cost naming.
36    pub named: Option<String>,
37    /// Whether this is an AltCost from the source card itself (adds OptionalCost.AltCost).
38    pub is_own_alt_cost: bool,
39}
40
41/// Collect all alternative costs that apply to `sa` cast by `player` from `source`.
42pub fn alternative_costs(
43    game: &GameState,
44    cards: &[Card],
45    sa: &SpellAbility,
46    source: &Card,
47    player: PlayerId,
48) -> Vec<AlternativeCostEntry> {
49    let mut result = Vec::new();
50
51    let source_id = source.id;
52
53    // Process source card first
54    collect_from_card(game, source, sa, source, player, &mut result);
55
56    // Process all cards in static ability source zones
57    for ca in cards
58        .iter()
59        .filter(|c| c.zone.is_static_ability_source() && c.id != source_id)
60    {
61        collect_from_card(game, ca, sa, source, player, &mut result);
62    }
63
64    result
65}
66
67/// Check if any alternative cost static applies to this card for this player.
68/// Convenience wrapper around `alternative_costs` — returns true if at least one
69/// alternative cost is available.
70pub fn has_alternative_cost(
71    game: &GameState,
72    cards: &[Card],
73    sa: &SpellAbility,
74    source: &Card,
75    player: PlayerId,
76) -> bool {
77    let source_id = source.id;
78
79    // Check source card first
80    for st_ab in source
81        .static_abilities
82        .iter()
83        .filter(|s| s.is_active_for(StaticMode::AlternativeCost, source.zone))
84    {
85        if !st_ab.check_conditions(source, game) {
86            continue;
87        }
88        if apply(st_ab, sa, source, source, player) {
89            return true;
90        }
91    }
92
93    // Check all cards in static ability source zones
94    for ca in cards
95        .iter()
96        .filter(|c| c.zone.is_static_ability_source() && c.id != source_id)
97    {
98        for st_ab in ca
99            .static_abilities
100            .iter()
101            .filter(|s| s.is_active_for(StaticMode::AlternativeCost, ca.zone))
102        {
103            if !st_ab.check_conditions(ca, game) {
104                continue;
105            }
106            if apply(st_ab, sa, source, ca, player) {
107                return true;
108            }
109        }
110    }
111
112    false
113}
114
115/// Process all static abilities on a single card, collecting matching
116/// alternative cost entries.
117fn collect_from_card(
118    game: &GameState,
119    ca: &Card,
120    sa: &SpellAbility,
121    source: &Card,
122    player: PlayerId,
123    result: &mut Vec<AlternativeCostEntry>,
124) {
125    for st_ab in ca
126        .static_abilities
127        .iter()
128        .filter(|s| s.is_active_for(StaticMode::AlternativeCost, ca.zone))
129    {
130        if !st_ab.check_conditions(ca, game) {
131            continue;
132        }
133        if !apply(st_ab, sa, source, ca, player) {
134            continue;
135        }
136
137        // Parse and substitute Cost$ param
138        let Some(cost_template_raw) = st_ab.ir.cost.as_deref() else {
139            continue;
140        };
141
142        // Replace "ConvertedManaCost" with the source card's CMC
143        let cmc = source.mana_value();
144        let cost_template = cost_template_raw.replace("ConvertedManaCost", &cmc.to_string());
145
146        let is_ability = sa.is_activated;
147        let is_spell = sa.is_spell;
148
149        let cost = parse_cost(&cost_template);
150
151        // Propagate XAlternative param
152        let x_alternative = st_ab.ir.x_alternative_text.clone();
153
154        // Propagate Announce param
155        let announce = st_ab.ir.announce_text.clone();
156
157        // Propagate ManaRestriction param
158        let mana_restriction = st_ab.ir.mana_restriction_text.clone();
159
160        // Zone restriction from ActiveZones — only if host card is not immutable
161        // (Rust Card has no is_immutable field yet, so we skip that guard)
162        // TODO: Add is_immutable check when Card gains that field
163        let zone_restriction = match st_ab.ir.active_zones.as_slice() {
164            [zone] => Some(*zone),
165            _ => None,
166        };
167
168        // StackDescription override
169        let stack_description = st_ab.ir.stack_description_text.clone();
170
171        let mut desc = String::new();
172        let cost_desc = if is_ability {
173            // CostDesc for abilities
174            let cd = if let Some(cd_param) = st_ab.ir.cost_desc_text.as_deref() {
175                // TODO: implement ManaCostParser.parse() equivalent
176                cd_param.to_string()
177            } else {
178                cost_template.clone()
179            };
180            desc.push_str(&cd);
181            Some(cd)
182        } else {
183            None
184        };
185
186        if is_spell {
187            // Append the original spell description
188            // TODO: sa.getDescription() equivalent — use ability_text as fallback
189            desc.push_str(&sa.ability_text);
190
191            // Check if source card is the host card of the static ability
192            if source.id == ca.id {
193                // Same card — use the Description param
194                if let Some(alt_desc) = st_ab.ir.description_text.as_deref() {
195                    desc.push_str(" (");
196                    desc.push_str(alt_desc);
197                    desc.push_str(") ");
198                }
199            } else {
200                // Different card — generic "by paying X instead of its mana cost"
201                desc.push_str(" (by paying ");
202                desc.push_str(&cost_template);
203                desc.push_str(" instead of its mana cost)");
204            }
205        }
206
207        // Whether this is the card's own alt cost (same host card)
208        let is_own_alt_cost = is_spell && source.id == ca.id;
209
210        // Named param override for custom cards
211        let named = st_ab.ir.named_text.clone();
212
213        result.push(AlternativeCostEntry {
214            cost,
215            cost_string: cost_template,
216            is_ability,
217            is_spell,
218            x_alternative,
219            announce,
220            mana_restriction,
221            zone_restriction,
222            stack_description,
223            cost_desc,
224            description: desc,
225            named,
226            is_own_alt_cost,
227        });
228    }
229}
230
231fn apply(
232    st_ab: &crate::staticability::StaticAbility,
233    sa: &SpellAbility,
234    source: &Card,
235    host: &Card,
236    player: PlayerId,
237) -> bool {
238    if let Some(valid_sa) = st_ab.ir.valid_sa.as_deref() {
239        if !matches_valid_sa(valid_sa, sa, host, Some(source)) {
240            return false;
241        }
242    }
243
244    if !valid_filter::matches_valid_card_selector_opt(st_ab.ir.valid_card.as_ref(), source, host) {
245        return false;
246    }
247
248    if !valid_filter::matches_valid_player_selector_opt(
249        st_ab.ir.valid_player.as_ref(),
250        player,
251        host.controller,
252    ) {
253        return false;
254    }
255
256    true
257}
258
259/// Apply an `AlternativeCostEntry` to a `SpellAbility`, mutating it in place
260pub fn apply_alternative_cost_to_sa(sa: &mut SpellAbility, entry: &AlternativeCostEntry) {
261    // Replace the pay costs with the alternative cost
262    sa.pay_costs = Some(entry.cost.clone());
263
264    // Mark as not a basic spell
265    // TODO: sa.setBasicSpell(false) — no field yet
266
267    if let Some(ref announce) = entry.announce {
268        sa.ir.announce_text = Some(announce.clone());
269    }
270    if let Some(ref stack_desc) = entry.stack_description {
271        sa.ir.stack_description_text = Some(stack_desc.clone());
272    }
273    if let Some(ref cost_desc) = entry.cost_desc {
274        sa.ir.precost_desc = Some(cost_desc.clone());
275    }
276
277    // Apply Named override
278    if let Some(ref named) = entry.named {
279        sa.ir.name_text = Some(named.clone());
280    }
281
282    let _ = &entry.x_alternative;
283    let _ = &entry.mana_restriction;
284
285    // TODO: Zone restriction on sa.restrictions when SpellAbilityRestriction is ported
286    // TODO: addOptionalCost(OptionalCost.AltCost) for is_own_alt_cost
287}