Skip to main content

manabrew_engine/ability/
ability_factory.rs

1//! AbilityFactory — factory for creating spell abilities from card scripts.
2//!
3//! Mirrors Java's `AbilityFactory.java`.
4//! Parses ability strings (AB$, SP$, DB$, ST$ prefixed) and constructs
5//! the corresponding `SpellAbility` with all sub-abilities resolved.
6
7use std::collections::HashMap;
8
9use crate::ability::api_type::ApiType;
10use crate::card::Card;
11use crate::cost::parse_cost;
12use crate::cost::{Cost, CostPart};
13use crate::game::GameState;
14use crate::ids::{CardId, PlayerId};
15use crate::parsing::keys::ST;
16use crate::parsing::{keys, Params, ParsedParams};
17use crate::spellability::target_restrictions::TargetRestrictions;
18use crate::spellability::{AbilityManaPart, SpellAbility, TargetChoices};
19use forge_foundation::ZoneType;
20use serde::{Deserialize, Serialize};
21
22/// The record type prefix for an ability definition.
23/// Mirrors Java's `AbilityFactory.AbilityRecordType`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25pub enum AbilityRecordType {
26    /// AB$ — activated ability
27    Ability,
28    /// SP$ — spell ability
29    #[default]
30    Spell,
31    /// ST$ — static ability
32    StaticAbility,
33    /// DB$ — sub-ability
34    SubAbility,
35}
36
37impl AbilityRecordType {
38    /// The script prefix for this record type.
39    pub fn prefix(&self) -> &'static str {
40        match self {
41            AbilityRecordType::Ability => "AB",
42            AbilityRecordType::Spell => "SP",
43            AbilityRecordType::StaticAbility => "ST",
44            AbilityRecordType::SubAbility => "DB",
45        }
46    }
47
48    /// Determine the record type from a parsed parameter map.
49    pub fn from_params(params: &Params) -> Option<AbilityRecordType> {
50        if params.has(keys::AB) {
51            Some(AbilityRecordType::Ability)
52        } else if params.has(keys::SP) {
53            Some(AbilityRecordType::Spell)
54        } else if params.has(ST) {
55            Some(AbilityRecordType::StaticAbility)
56        } else if params.has(keys::DB) {
57            Some(AbilityRecordType::SubAbility)
58        } else {
59            None
60        }
61    }
62
63    /// Determine the record type from raw ability text without building a
64    /// temporary params map just for the AB/SP/ST/DB probe.
65    pub fn from_raw(raw: &str) -> Option<AbilityRecordType> {
66        if crate::parsing::raw_has_key(raw, keys::AB) {
67            Some(AbilityRecordType::Ability)
68        } else if crate::parsing::raw_has_key(raw, keys::SP) {
69            Some(AbilityRecordType::Spell)
70        } else if crate::parsing::raw_has_key(raw, ST) {
71            Some(AbilityRecordType::StaticAbility)
72        } else if crate::parsing::raw_has_key(raw, keys::DB) {
73            Some(AbilityRecordType::SubAbility)
74        } else {
75            None
76        }
77    }
78
79    pub fn from_parsed(params: &ParsedParams<'_>) -> Option<AbilityRecordType> {
80        if params.has(keys::AB) {
81            Some(AbilityRecordType::Ability)
82        } else if params.has(keys::SP) {
83            Some(AbilityRecordType::Spell)
84        } else if params.has(ST) {
85            Some(AbilityRecordType::StaticAbility)
86        } else if params.has(keys::DB) {
87            Some(AbilityRecordType::SubAbility)
88        } else {
89            None
90        }
91    }
92
93    /// Java-name alias for `from_params`. Mirrors
94    /// `AbilityFactory.AbilityRecordType.getRecordType(Map)`.
95    pub fn get_record_type(params: &Params) -> Option<AbilityRecordType> {
96        Self::from_params(params)
97    }
98
99    /// Get the API type string from parsed parameters for this record type.
100    pub fn api_type_of<'a>(&self, params: &'a Params) -> Option<&'a str> {
101        params.get(self.prefix())
102    }
103
104    /// Resolve the `ApiType` enum for a parsed parameter map, mirroring
105    /// Java `AbilityFactory.AbilityRecordType.getApiTypeOf(Map)`.
106    pub fn get_api_type_of(&self, params: &Params) -> Option<crate::ability::api_type::ApiType> {
107        self.api_type_of(params)
108            .and_then(crate::ability::api_type::ApiType::smart_value_of)
109    }
110}
111
112/// Java-name alias for `build_spell_ability_from_host_card`. Mirrors
113/// `AbilityFactory.getAbility(String abString, Card card)`.
114pub fn get_ability(
115    host: &crate::card::Card,
116    ability_text: &str,
117    player: crate::ids::PlayerId,
118) -> crate::spellability::SpellAbility {
119    build_spell_ability_from_host_card(host, ability_text, player)
120}
121
122/// Keys used for additional sub-abilities in ability scripts.
123/// Mirrors Java's `AbilityFactory.additionalAbilityKeys`.
124pub const ADDITIONAL_ABILITY_KEYS: &[&str] = &[
125    "WinSubAbility",
126    "OtherwiseSubAbility",
127    "BidSubAbility",
128    "ChooseNumberSubAbility",
129    "Lowest",
130    "Highest",
131    "NotLowest",
132    "GuessCorrect",
133    "GuessWrong",
134    "MatchedAbility",
135    "UnmatchedAbility",
136    "HeadsSubAbility",
137    "TailsSubAbility",
138    "LoseSubAbility",
139    "TrueSubAbility",
140    "FalseSubAbility",
141    "ChosenPile",
142    "UnchosenPile",
143    "RepeatSubAbility",
144    "Execute",
145    "FallbackAbility",
146    "ChooseSubAbility",
147    "CantChooseSubAbility",
148    "RegenerationAbility",
149    "ReturnAbility",
150    "GiftAbility",
151    "VoteSubAbility",
152    "VoteTiedAbility",
153];
154
155const MAX_SUB_ABILITY_CHAIN_DEPTH: usize = 50;
156
157thread_local! {
158    static SUB_ABILITY_CHAIN_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
159}
160
161const RESTRICTION_KEYS: &[&str] = &[
162    "Activation",
163    "ActivationZone",
164    "ActivationPhases",
165    "SorcerySpeed",
166    "InstantSpeed",
167    "Activator",
168    "PlayerTurn",
169    "OpponentTurn",
170    "ActivationLimit",
171    "GameActivationLimit",
172    "Threshold",
173    "Metalcraft",
174    "Delirium",
175    "Hellbent",
176    "Revolt",
177    "Desert",
178    "Blessing",
179    "Solved",
180    "IsPresent",
181    "PresentCompare",
182    "PresentZone",
183    "PresentDefined",
184    "ClassLevel",
185    "ActivateCardsInHand",
186];
187
188const CONDITION_KEYS: &[&str] = &[
189    "ConditionPhases",
190    "ConditionPlayerTurn",
191    "ConditionOpponentTurn",
192    "ConditionThreshold",
193    "ConditionMetalcraft",
194    "ConditionDelirium",
195    "ConditionHellbent",
196    "ConditionRevolt",
197    "ConditionDesert",
198    "ConditionBlessing",
199    "ConditionSolved",
200    "ConditionPresent",
201    "ConditionCompare",
202    "ConditionPresentZone",
203    "ConditionDefined",
204];
205
206/// Parse a pipe-delimited ability string into a key-value map.
207/// Mirrors Java's `AbilityFactory.getMapParams()`.
208pub fn get_map_params(ab_string: &str) -> HashMap<String, String> {
209    let mut map = HashMap::new();
210    for segment in ab_string.split('|') {
211        let segment = segment.trim();
212        if let Some(idx) = segment.find('$') {
213            let key = segment[..idx].trim().to_string();
214            let value = segment[idx + 1..].trim().to_string();
215            map.insert(key, value);
216        }
217    }
218    map
219}
220
221/// Build a SpellAbility chain from a card's ability text, walking SubAbility$
222/// SVars to construct the linked list.
223/// Mirrors Java's `AbilityFactory.getAbility()` + sub-ability chain construction.
224pub fn build_spell_ability(
225    game: &GameState,
226    card_id: CardId,
227    ability_text: &str,
228    player: PlayerId,
229) -> SpellAbility {
230    let host = game.card(card_id);
231    build_spell_ability_from_host_card(host, ability_text, player)
232}
233
234/// Build a SpellAbility chain from script text using a concrete host card.
235///
236/// This is the closest Rust equivalent of Java `AbilityFactory.getAbility(...)`
237/// for contexts that have a `Card` object but not full `GameState`.
238pub fn build_spell_ability_from_host_card(
239    host: &Card,
240    ability_text: &str,
241    player: PlayerId,
242) -> SpellAbility {
243    let _perf_scope =
244        crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::AbilityBuild);
245    crate::perf::increment_params_parse();
246    let parsed = ParsedParams::parse(ability_text);
247    let record_type = AbilityRecordType::from_parsed(&parsed).unwrap_or_else(|| {
248        panic!(
249            "AbilityFactory::build_spell_ability requires AB$/SP$/ST$/DB$ ability text; got: {:?}",
250            ability_text
251        )
252    });
253    let params = Params::from_parsed(&parsed);
254    build_spell_ability_of_type_with_params(
255        host,
256        ability_text,
257        player,
258        record_type,
259        &parsed,
260        params,
261    )
262}
263
264/// Build a spell ability for card-casting contexts.
265///
266/// This mirrors Java's Spell object construction for vanilla cards:
267/// if a card has no explicit SP$ line, create a spell-shaped ability probe
268/// from the card's intrinsic mana cost and default hand-zone restriction.
269pub fn build_spell_ability_for_card_cast(
270    game: &GameState,
271    card_id: CardId,
272    player: PlayerId,
273) -> SpellAbility {
274    let _perf_scope =
275        crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::AbilityBuild);
276    if let Some(spell_ability_text) = game
277        .card(card_id)
278        .abilities
279        .iter()
280        .find(|a| crate::parsing::raw_has_key(a, keys::SP))
281        .cloned()
282    {
283        let host = game.card(card_id);
284        let mut sa = build_spell_ability_of_type(
285            host,
286            &spell_ability_text,
287            player,
288            AbilityRecordType::Spell,
289        );
290        // Card-cast context: if SP$ omitted Cost$, default to card mana cost.
291        if sa.pay_costs.is_none() {
292            sa.pay_costs = Some(Cost {
293                parts: vec![CostPart::Mana {
294                    cost: host.mana_cost.clone(),
295                    x_min: 0,
296                    is_exiled_creature_cost: false,
297                    is_enchanted_creature_cost: false,
298                    is_cost_pay_any_number_of_times: false,
299                    max_waterbend: None,
300                }],
301                has_tap: false,
302                mandatory: false,
303            });
304        }
305        // Aura enchantments with SP$ but no ValidTgts$: inject Enchant-derived targeting.
306        // Some aura cards have SP$ lines for ETB effects but rely on the Enchant keyword
307        // for targeting. Without this, the aura can target anything.
308        if sa.target_restrictions.is_none() && host.type_line.has_subtype("Aura") {
309            let enchant_type = host.get_keyword_cost("Enchant").unwrap_or_default();
310            let params_str = crate::parsing::enchant_type_to_target_params(&enchant_type);
311            sa.target_restrictions = TargetRestrictions::new(&Params::from_raw(&params_str));
312        }
313        return sa;
314    }
315
316    // Vanilla fallback: no SP$ ability text. Build a castable spell probe
317    // with Java-like Spell defaults (hand zone + card mana cost).
318    let mut restriction = crate::spellability::SpellAbilityRestriction::default();
319    restriction.variables.set_zone(ZoneType::Hand);
320    let condition = crate::spellability::SpellAbilityCondition::default();
321    let card = game.card(card_id);
322
323    // Aura enchantments: derive targeting from "Enchant <type>" keyword.
324    // Mirrors Java's Spell constructor which reads the Enchant keyword to
325    // set up ValidTgts$ automatically for aura spells.
326    let target_restrictions = if card.type_line.has_subtype("Aura") {
327        let enchant_type = card.get_keyword_cost("Enchant").unwrap_or_default();
328        let params_str = crate::parsing::enchant_type_to_target_params(&enchant_type);
329        TargetRestrictions::new(&Params::from_raw(&params_str))
330    } else {
331        None
332    };
333
334    SpellAbility {
335        id: 0,
336        api: None,
337        source: Some(card_id),
338        original_host: card.effect_source,
339        activating_player: player,
340        targeting_player: None,
341        ability_text: String::new(),
342        record_type: AbilityRecordType::Spell,
343        ir: crate::ability::ability_ir::SpellAbilityIr::default(),
344        target_restrictions,
345        target_chosen: TargetChoices::default(),
346        pay_costs: Some(Cost {
347            parts: vec![CostPart::Mana {
348                cost: card.mana_cost.clone(),
349                x_min: 0,
350                is_exiled_creature_cost: false,
351                is_enchanted_creature_cost: false,
352                is_cost_pay_any_number_of_times: false,
353                max_waterbend: None,
354            }],
355            has_tap: false,
356            mandatory: false,
357        }),
358        sub_ability: None,
359        wrapped_ability: None,
360        is_spell: true,
361        is_trigger: false,
362        is_activated: false,
363        intrinsic: false,
364        trigger_source: None,
365        trigger_source_zone_timestamp: None,
366        source_zone_timestamp: Some(card.zone_timestamp),
367        source_trigger_id: None,
368        trigger_index: None,
369        alt_cost: None,
370        alt_cost_index: 0,
371        evoke_keyword_count: 0,
372        kicked: false,
373        buyback_paid: false,
374        overloaded: false,
375        is_copy: false,
376        paid_life_amount: 0,
377        kick_count: 0,
378        replicate_count: 0,
379        optional_generic_cost_paid: false,
380        trigger_remembered_amount: 0,
381        x_mana_cost_paid: 0,
382        discarded_cost_cards: Vec::new(),
383        optional_costs: Vec::new(),
384        paid_hash: std::collections::HashMap::new(),
385        paying_mana: Vec::new(),
386        paid_abilities: Vec::new(),
387        mana_part: None,
388        express_mana_choice: None,
389        convoke_tapped: Vec::new(),
390        spliced_cards: Vec::new(),
391        announce_vars: std::collections::HashMap::new(),
392        sacrificed_as_emerge: None,
393        sacrificed_as_offering: None,
394        description: String::new(),
395        stack_description: String::new(),
396        is_mana_ability: false,
397        is_land_ability: false,
398        cast_face_down: false,
399        trigger_objects: std::collections::HashMap::new(),
400        trigger_spell_abilities: std::collections::HashMap::new(),
401        additional_ability_lists: std::collections::HashMap::new(),
402        replacing_objects: std::collections::HashMap::new(),
403        trigger_remembered: Vec::new(),
404        restriction,
405        condition,
406        rollback_effects: Vec::new(),
407        optional_keyword_amounts: std::collections::HashMap::new(),
408        pips_to_reduce: Vec::new(),
409        may_choose_new_targets: false,
410        last_state: std::collections::HashMap::new(),
411        change_zone_table: None,
412        damage_map: None,
413        prevent_map: None,
414    }
415}
416
417fn build_spell_ability_of_type(
418    host: &Card,
419    ability_text: &str,
420    player: PlayerId,
421    record_type: AbilityRecordType,
422) -> SpellAbility {
423    let _perf_scope =
424        crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::AbilityBuild);
425    crate::perf::increment_params_parse();
426    let parsed = ParsedParams::parse(ability_text);
427    let params = Params::from_parsed(&parsed);
428    build_spell_ability_of_type_with_params(
429        host,
430        ability_text,
431        player,
432        record_type,
433        &parsed,
434        params,
435    )
436}
437
438fn build_spell_ability_of_type_with_params(
439    host: &Card,
440    ability_text: &str,
441    player: PlayerId,
442    record_type: AbilityRecordType,
443    parsed: &ParsedParams<'_>,
444    params: Params,
445) -> SpellAbility {
446    let api = parsed
447        .get(record_type.prefix())
448        .and_then(ApiType::smart_value_of);
449    let mut ir = crate::ability::ability_ir::SpellAbilityIr::from_parsed(api, parsed);
450    ir.compile_numeric_params_from_runtime(&params);
451    let target_restrictions = if parsed.has(keys::VALID_TGTS) {
452        TargetRestrictions::new_from_parsed(parsed, &params)
453    } else {
454        None
455    };
456    let cost = if record_type != AbilityRecordType::SubAbility {
457        parsed.get(keys::COST).map(parse_cost)
458    } else {
459        None
460    };
461    let mut restriction = crate::spellability::SpellAbilityRestriction::default();
462    let mut condition = crate::spellability::SpellAbilityCondition::default();
463
464    match record_type {
465        AbilityRecordType::Spell => restriction.variables.set_zone(ZoneType::Hand),
466        AbilityRecordType::Ability
467        | AbilityRecordType::StaticAbility
468        | AbilityRecordType::SubAbility => restriction.variables.set_zone(ZoneType::Battlefield),
469    }
470    if parsed.has_any(RESTRICTION_KEYS) {
471        restriction.set_restrictions_parsed(parsed);
472    }
473    if parsed.has_any(CONDITION_KEYS) {
474        condition.set_conditions_parsed(parsed);
475    }
476
477    // Recursively build sub-ability chain from SVars
478    let sub_ability = if let Some(sub_svar_name) = parsed.get(keys::SUB_ABILITY) {
479        let depth = SUB_ABILITY_CHAIN_DEPTH.with(|d| d.get());
480        if depth >= MAX_SUB_ABILITY_CHAIN_DEPTH {
481            eprintln!(
482                "SubAbility chain exceeded depth limit on {}, stopping at: {sub_svar_name}",
483                host.card_name
484            );
485            None
486        } else {
487            host.get_s_var(sub_svar_name)
488                .map(str::to_string)
489                .map(|sub_text| {
490                    SUB_ABILITY_CHAIN_DEPTH.with(|d| d.set(depth + 1));
491                    let sub = Box::new(build_spell_ability_from_host_card(host, &sub_text, player));
492                    SUB_ABILITY_CHAIN_DEPTH.with(|d| d.set(depth));
493                    sub
494                })
495        }
496    } else {
497        None
498    };
499
500    let mana_part = if parsed.has(keys::PRODUCED) {
501        build_mana_part_from_parsed(parsed)
502    } else {
503        None
504    };
505    let mut sa = SpellAbility {
506        id: 0,
507        api,
508        source: Some(host.id),
509        original_host: host.effect_source,
510        activating_player: player,
511        targeting_player: None,
512        ability_text: ability_text.to_string(),
513        record_type,
514        ir,
515        target_restrictions,
516        target_chosen: TargetChoices::default(),
517        pay_costs: cost,
518        sub_ability,
519        wrapped_ability: None,
520        is_spell: record_type == AbilityRecordType::Spell,
521        is_trigger: false,
522        is_activated: record_type == AbilityRecordType::Ability,
523        intrinsic: false,
524        trigger_source: None,
525        trigger_source_zone_timestamp: None,
526        source_zone_timestamp: Some(host.zone_timestamp),
527        source_trigger_id: None,
528        trigger_index: None,
529        alt_cost: None,
530        alt_cost_index: 0,
531        evoke_keyword_count: 0,
532        kicked: false,
533        buyback_paid: false,
534        overloaded: false,
535        is_copy: false,
536        paid_life_amount: 0,
537        kick_count: 0,
538        replicate_count: 0,
539        optional_generic_cost_paid: false,
540        trigger_remembered_amount: 0,
541        x_mana_cost_paid: 0,
542        discarded_cost_cards: Vec::new(),
543        optional_costs: Vec::new(),
544        paid_hash: std::collections::HashMap::new(),
545        paying_mana: Vec::new(),
546        paid_abilities: Vec::new(),
547        mana_part,
548        express_mana_choice: None,
549        convoke_tapped: Vec::new(),
550        spliced_cards: Vec::new(),
551        announce_vars: std::collections::HashMap::new(),
552        sacrificed_as_emerge: None,
553        sacrificed_as_offering: None,
554        description: String::new(),
555        stack_description: String::new(),
556        is_mana_ability: false,
557        is_land_ability: false,
558        cast_face_down: false,
559        trigger_objects: std::collections::HashMap::new(),
560        trigger_spell_abilities: std::collections::HashMap::new(),
561        additional_ability_lists: std::collections::HashMap::new(),
562        replacing_objects: std::collections::HashMap::new(),
563        trigger_remembered: Vec::new(),
564        restriction,
565        condition,
566        rollback_effects: Vec::new(),
567        optional_keyword_amounts: std::collections::HashMap::new(),
568        pips_to_reduce: Vec::new(),
569        may_choose_new_targets: false,
570        last_state: std::collections::HashMap::new(),
571        change_zone_table: None,
572        damage_map: None,
573        prevent_map: None,
574    };
575    if let Some(api) = api {
576        crate::ability::effects::build_spell_ability_for_api(api, &mut sa);
577    }
578    sa
579}
580
581#[allow(dead_code)]
582fn build_mana_part(params: &Params) -> Option<AbilityManaPart> {
583    let produced = params.get(keys::PRODUCED)?;
584    let mut mana_part = AbilityManaPart::new(produced, params.get(keys::RESTRICTION).unwrap_or(""));
585    mana_part.set_adds_keywords(params.get(keys::ADDS_KEYWORDS).map(str::to_string));
586    mana_part.set_triggers_when_spent(params.get(keys::TRIGGERS_WHEN_SPENT).map(str::to_string));
587    mana_part.set_persistent_mana(params.has("PersistentMana"));
588    mana_part.set_combat_mana(params.has("CombatMana"));
589    Some(mana_part)
590}
591
592fn build_mana_part_from_parsed(params: &ParsedParams<'_>) -> Option<AbilityManaPart> {
593    let produced = params.get(keys::PRODUCED)?;
594    let mut mana_part = AbilityManaPart::new(produced, params.get(keys::RESTRICTION).unwrap_or(""));
595    mana_part.set_adds_keywords(params.get(keys::ADDS_KEYWORDS).map(str::to_string));
596    mana_part.set_triggers_when_spent(params.get(keys::TRIGGERS_WHEN_SPENT).map(str::to_string));
597    mana_part.set_persistent_mana(params.has("PersistentMana"));
598    mana_part.set_combat_mana(params.has("CombatMana"));
599    Some(mana_part)
600}
601
602/// Parse the cost of an ability from its parameters.
603/// Mirrors Java's `AbilityFactory.parseAbilityCost(Card, MapOfParams, RecordType)`.
604///
605/// For AB$ (activated) and SP$ (spell) abilities, reads the Cost$ parameter
606/// and parses it into a Cost structure. Sub-abilities (DB$) have no cost.
607pub fn parse_ability_cost(
608    _host: &Card,
609    params: &Params,
610    record_type: AbilityRecordType,
611) -> Option<Cost> {
612    if record_type == AbilityRecordType::SubAbility {
613        return None;
614    }
615    params.get(keys::COST).map(parse_cost)
616}
617
618/// Adjust the change-zone target for effects that move cards between zones.
619/// Mirrors Java's `AbilityFactory.adjustChangeZoneTarget(MapOfParams, SpellAbility)`.
620///
621/// When a change-zone effect specifies `ChangeZoneTable$`, this function
622/// adjusts the target resolution to use the table mapping instead of
623/// the standard Defined$/Targeted resolution.
624pub fn adjust_change_zone_target(sa: &mut SpellAbility, game: &GameState) {
625    // If the SA has ChangeZoneTable, apply table-based targeting
626    if sa.ir.change_zone_table {
627        // The change_zone_table is populated during resolution by the effect.
628        // This function sets up the SA to use table-based targeting by
629        // ensuring the table exists.
630        if sa.change_zone_table.is_none() {
631            sa.change_zone_table = Some(crate::card::card_zone_table::CardZoneTable::default());
632        }
633    }
634
635    // Handle "Hidden" origin — if the origin zone is hidden (Library, Hand),
636    // adjust the target validation accordingly
637    if let Some(origin) = sa.ir.origin_zone {
638        let _ = game; // May need game state for validation in future
639        if matches!(
640            origin,
641            forge_foundation::ZoneType::Library | forge_foundation::ZoneType::Hand
642        ) {
643            // Hidden zones use different targeting rules
644            sa.ir.hidden = true;
645        }
646    }
647}
648
649/// Build a fused spell ability for split/fused cards.
650/// Mirrors Java's `AbilityFactory.buildFusedAbility(Card)`.
651///
652/// Fuse cards (e.g. Fire // Ice with Fuse) allow casting both halves as a
653/// single spell. This function creates a combined SpellAbility that chains
654/// the left and right halves together.
655pub fn build_fused_ability(
656    game: &GameState,
657    card_id: CardId,
658    player: PlayerId,
659) -> Option<SpellAbility> {
660    let card = game.card(card_id);
661
662    // Check if the card has the Fuse keyword
663    let has_fuse = card.keywords.contains_string_ignore_case("Fuse")
664        || card.granted_keywords.contains_string_ignore_case("Fuse");
665
666    if !has_fuse {
667        return None;
668    }
669
670    // A fused card needs at least 2 ability lines (one per half)
671    if card.abilities.len() < 2 {
672        return None;
673    }
674
675    // Build the first half
676    let host = game.card(card_id);
677    let mut left_sa = build_spell_ability_from_host_card(host, &card.abilities[0], player);
678    left_sa.source = Some(card_id);
679
680    // Build the second half and append as sub-ability
681    let right_sa = build_spell_ability_from_host_card(host, &card.abilities[1], player);
682
683    // Append right half to left half
684    let mut slot = &mut left_sa.sub_ability;
685    loop {
686        match slot {
687            Some(node) => slot = &mut node.sub_ability,
688            None => {
689                *slot = Some(Box::new(right_sa));
690                break;
691            }
692        }
693    }
694
695    // Combine the costs
696    if let (Some(left_cost), Some(right_cost)) = (
697        &left_sa.pay_costs,
698        &card.abilities.get(1).and_then(|text| {
699            let params = Params::from_raw(text);
700            params.get(keys::COST).map(parse_cost)
701        }),
702    ) {
703        let mut combined_parts = left_cost.parts.clone();
704        combined_parts.extend(right_cost.parts.clone());
705        left_sa.pay_costs = Some(Cost {
706            parts: combined_parts,
707            has_tap: left_cost.has_tap || right_cost.has_tap,
708            mandatory: false,
709        });
710    }
711
712    left_sa.description = format!("Fuse (Cast both halves of {})", card.card_name);
713
714    Some(left_sa)
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720
721    #[test]
722    fn test_get_map_params() {
723        let input = "AB$ DealDamage | Cost$ T | NumDmg$ 1";
724        let map = get_map_params(input);
725        assert_eq!(map.get("AB").unwrap(), "DealDamage");
726        assert_eq!(map.get("Cost").unwrap(), "T");
727        assert_eq!(map.get("NumDmg").unwrap(), "1");
728    }
729
730    #[test]
731    fn test_record_type_from_params() {
732        let params = Params::from_raw("DB$ Draw | NumCards$ 2");
733        assert_eq!(
734            AbilityRecordType::from_params(&params),
735            Some(AbilityRecordType::SubAbility)
736        );
737    }
738
739    #[test]
740    fn test_record_type_from_params_static() {
741        let params = Params::from_raw("ST$ Continuous");
742        assert_eq!(
743            AbilityRecordType::from_params(&params),
744            Some(AbilityRecordType::StaticAbility)
745        );
746    }
747}