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: {ability_text:?}"
250        )
251    });
252    let params = Params::from_parsed(&parsed);
253    build_spell_ability_of_type_with_params(
254        host,
255        ability_text,
256        player,
257        record_type,
258        &parsed,
259        params,
260    )
261}
262
263/// Build a spell ability for card-casting contexts.
264///
265/// This mirrors Java's Spell object construction for vanilla cards:
266/// if a card has no explicit SP$ line, create a spell-shaped ability probe
267/// from the card's intrinsic mana cost and default hand-zone restriction.
268pub fn build_spell_ability_for_card_cast(
269    game: &GameState,
270    card_id: CardId,
271    player: PlayerId,
272) -> SpellAbility {
273    let _perf_scope =
274        crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::AbilityBuild);
275    if let Some(spell_ability_text) = game
276        .card(card_id)
277        .abilities
278        .iter()
279        .find(|a| crate::parsing::raw_has_key(a, keys::SP))
280        .cloned()
281    {
282        let host = game.card(card_id);
283        let mut sa = build_spell_ability_of_type(
284            host,
285            &spell_ability_text,
286            player,
287            AbilityRecordType::Spell,
288        );
289        // Card-cast context: if SP$ omitted Cost$, default to card mana cost.
290        if sa.pay_costs.is_none() {
291            sa.pay_costs = Some(Cost {
292                parts: vec![CostPart::Mana {
293                    cost: host.mana_cost.clone(),
294                    x_min: 0,
295                    is_exiled_creature_cost: false,
296                    is_enchanted_creature_cost: false,
297                    is_cost_pay_any_number_of_times: false,
298                    max_waterbend: None,
299                }],
300                has_tap: false,
301                mandatory: false,
302            });
303        }
304        // Aura enchantments with SP$ but no ValidTgts$: inject Enchant-derived targeting.
305        // Some aura cards have SP$ lines for ETB effects but rely on the Enchant keyword
306        // for targeting. Without this, the aura can target anything.
307        if sa.target_restrictions.is_none() && host.type_line.has_subtype("Aura") {
308            let enchant_type = host.get_keyword_cost("Enchant").unwrap_or_default();
309            let params_str = crate::parsing::enchant_type_to_target_params(&enchant_type);
310            sa.target_restrictions = TargetRestrictions::new(&Params::from_raw(&params_str));
311        }
312        return sa;
313    }
314
315    // Vanilla fallback: no SP$ ability text. Build a castable spell probe
316    // with Java-like Spell defaults (hand zone + card mana cost).
317    let mut restriction = crate::spellability::SpellAbilityRestriction::default();
318    restriction.variables.set_zone(ZoneType::Hand);
319    let condition = crate::spellability::SpellAbilityCondition::default();
320    let card = game.card(card_id);
321
322    // Aura enchantments: derive targeting from "Enchant <type>" keyword.
323    // Mirrors Java's Spell constructor which reads the Enchant keyword to
324    // set up ValidTgts$ automatically for aura spells.
325    let target_restrictions = if card.type_line.has_subtype("Aura") {
326        let enchant_type = card.get_keyword_cost("Enchant").unwrap_or_default();
327        let params_str = crate::parsing::enchant_type_to_target_params(&enchant_type);
328        TargetRestrictions::new(&Params::from_raw(&params_str))
329    } else {
330        None
331    };
332
333    SpellAbility {
334        id: 0,
335        api: None,
336        source: Some(card_id),
337        original_host: card.effect_source,
338        activating_player: player,
339        targeting_player: None,
340        ability_text: String::new(),
341        record_type: AbilityRecordType::Spell,
342        ir: crate::ability::ability_ir::SpellAbilityIr::default(),
343        target_restrictions,
344        target_chosen: TargetChoices::default(),
345        pay_costs: Some(Cost {
346            parts: vec![CostPart::Mana {
347                cost: card.mana_cost.clone(),
348                x_min: 0,
349                is_exiled_creature_cost: false,
350                is_enchanted_creature_cost: false,
351                is_cost_pay_any_number_of_times: false,
352                max_waterbend: None,
353            }],
354            has_tap: false,
355            mandatory: false,
356        }),
357        sub_ability: None,
358        wrapped_ability: None,
359        is_spell: true,
360        is_trigger: false,
361        is_activated: false,
362        intrinsic: false,
363        trigger_source: None,
364        trigger_source_zone_timestamp: None,
365        source_zone_timestamp: Some(card.zone_timestamp),
366        source_trigger_id: None,
367        trigger_index: None,
368        alt_cost: None,
369        alt_cost_index: 0,
370        evoke_keyword_count: 0,
371        kicked: false,
372        buyback_paid: false,
373        overloaded: false,
374        is_copy: false,
375        paid_life_amount: 0,
376        kick_count: 0,
377        replicate_count: 0,
378        optional_generic_cost_paid: false,
379        trigger_remembered_amount: 0,
380        x_mana_cost_paid: 0,
381        discarded_cost_cards: Vec::new(),
382        optional_costs: Vec::new(),
383        paid_hash: std::collections::HashMap::new(),
384        paying_mana: Vec::new(),
385        paid_abilities: Vec::new(),
386        mana_part: None,
387        express_mana_choice: None,
388        convoke_tapped: Vec::new(),
389        spliced_cards: Vec::new(),
390        announce_vars: std::collections::HashMap::new(),
391        sacrificed_as_emerge: None,
392        sacrificed_as_offering: None,
393        description: String::new(),
394        stack_description: String::new(),
395        is_mana_ability: false,
396        is_land_ability: false,
397        cast_face_down: false,
398        trigger_objects: std::collections::HashMap::new(),
399        trigger_spell_abilities: std::collections::HashMap::new(),
400        additional_ability_lists: std::collections::HashMap::new(),
401        replacing_objects: std::collections::HashMap::new(),
402        trigger_remembered: Vec::new(),
403        restriction,
404        condition,
405        rollback_effects: Vec::new(),
406        optional_keyword_amounts: std::collections::HashMap::new(),
407        pips_to_reduce: Vec::new(),
408        may_choose_new_targets: false,
409        last_state: std::collections::HashMap::new(),
410        change_zone_table: None,
411        damage_map: None,
412        prevent_map: None,
413    }
414}
415
416fn build_spell_ability_of_type(
417    host: &Card,
418    ability_text: &str,
419    player: PlayerId,
420    record_type: AbilityRecordType,
421) -> SpellAbility {
422    let _perf_scope =
423        crate::perf::ParamsLookupScopeGuard::enter(crate::perf::ParamsLookupScope::AbilityBuild);
424    crate::perf::increment_params_parse();
425    let parsed = ParsedParams::parse(ability_text);
426    let params = Params::from_parsed(&parsed);
427    build_spell_ability_of_type_with_params(
428        host,
429        ability_text,
430        player,
431        record_type,
432        &parsed,
433        params,
434    )
435}
436
437fn build_spell_ability_of_type_with_params(
438    host: &Card,
439    ability_text: &str,
440    player: PlayerId,
441    record_type: AbilityRecordType,
442    parsed: &ParsedParams<'_>,
443    params: Params,
444) -> SpellAbility {
445    let api = parsed
446        .get(record_type.prefix())
447        .and_then(ApiType::smart_value_of);
448    let mut ir = crate::ability::ability_ir::SpellAbilityIr::from_parsed(api, parsed);
449    ir.compile_numeric_params_from_runtime(&params);
450    let target_restrictions = if parsed.has(keys::VALID_TGTS) {
451        TargetRestrictions::new_from_parsed(parsed, &params)
452    } else {
453        None
454    };
455    let cost = if record_type != AbilityRecordType::SubAbility {
456        parsed.get(keys::COST).map(parse_cost)
457    } else {
458        None
459    };
460    let mut restriction = crate::spellability::SpellAbilityRestriction::default();
461    let mut condition = crate::spellability::SpellAbilityCondition::default();
462
463    match record_type {
464        AbilityRecordType::Spell => restriction.variables.set_zone(ZoneType::Hand),
465        AbilityRecordType::Ability
466        | AbilityRecordType::StaticAbility
467        | AbilityRecordType::SubAbility => restriction.variables.set_zone(ZoneType::Battlefield),
468    }
469    if parsed.has_any(RESTRICTION_KEYS) {
470        restriction.set_restrictions_parsed(parsed);
471    }
472    if parsed.has_any(CONDITION_KEYS) {
473        condition.set_conditions_parsed(parsed);
474    }
475
476    // Recursively build sub-ability chain from SVars
477    let sub_ability = if let Some(sub_svar_name) = parsed.get(keys::SUB_ABILITY) {
478        let depth = SUB_ABILITY_CHAIN_DEPTH.with(|d| d.get());
479        if depth >= MAX_SUB_ABILITY_CHAIN_DEPTH {
480            eprintln!(
481                "SubAbility chain exceeded depth limit on {}, stopping at: {sub_svar_name}",
482                host.card_name
483            );
484            None
485        } else {
486            host.get_s_var(sub_svar_name)
487                .map(str::to_string)
488                .map(|sub_text| {
489                    SUB_ABILITY_CHAIN_DEPTH.with(|d| d.set(depth + 1));
490                    let sub = Box::new(build_spell_ability_from_host_card(host, &sub_text, player));
491                    SUB_ABILITY_CHAIN_DEPTH.with(|d| d.set(depth));
492                    sub
493                })
494        }
495    } else {
496        None
497    };
498
499    let mana_part = if parsed.has(keys::PRODUCED) {
500        build_mana_part_from_parsed(parsed)
501    } else {
502        None
503    };
504    let mut sa = SpellAbility {
505        id: 0,
506        api,
507        source: Some(host.id),
508        original_host: host.effect_source,
509        activating_player: player,
510        targeting_player: None,
511        ability_text: ability_text.to_string(),
512        record_type,
513        ir,
514        target_restrictions,
515        target_chosen: TargetChoices::default(),
516        pay_costs: cost,
517        sub_ability,
518        wrapped_ability: None,
519        is_spell: record_type == AbilityRecordType::Spell,
520        is_trigger: false,
521        is_activated: record_type == AbilityRecordType::Ability,
522        intrinsic: false,
523        trigger_source: None,
524        trigger_source_zone_timestamp: None,
525        source_zone_timestamp: Some(host.zone_timestamp),
526        source_trigger_id: None,
527        trigger_index: None,
528        alt_cost: None,
529        alt_cost_index: 0,
530        evoke_keyword_count: 0,
531        kicked: false,
532        buyback_paid: false,
533        overloaded: false,
534        is_copy: false,
535        paid_life_amount: 0,
536        kick_count: 0,
537        replicate_count: 0,
538        optional_generic_cost_paid: false,
539        trigger_remembered_amount: 0,
540        x_mana_cost_paid: 0,
541        discarded_cost_cards: Vec::new(),
542        optional_costs: Vec::new(),
543        paid_hash: std::collections::HashMap::new(),
544        paying_mana: Vec::new(),
545        paid_abilities: Vec::new(),
546        mana_part,
547        express_mana_choice: None,
548        convoke_tapped: Vec::new(),
549        spliced_cards: Vec::new(),
550        announce_vars: std::collections::HashMap::new(),
551        sacrificed_as_emerge: None,
552        sacrificed_as_offering: None,
553        description: String::new(),
554        stack_description: String::new(),
555        is_mana_ability: false,
556        is_land_ability: false,
557        cast_face_down: false,
558        trigger_objects: std::collections::HashMap::new(),
559        trigger_spell_abilities: std::collections::HashMap::new(),
560        additional_ability_lists: std::collections::HashMap::new(),
561        replacing_objects: std::collections::HashMap::new(),
562        trigger_remembered: Vec::new(),
563        restriction,
564        condition,
565        rollback_effects: Vec::new(),
566        optional_keyword_amounts: std::collections::HashMap::new(),
567        pips_to_reduce: Vec::new(),
568        may_choose_new_targets: false,
569        last_state: std::collections::HashMap::new(),
570        change_zone_table: None,
571        damage_map: None,
572        prevent_map: None,
573    };
574    if let Some(api) = api {
575        crate::ability::effects::build_spell_ability_for_api(api, &mut sa);
576    }
577    sa
578}
579
580#[allow(dead_code)]
581fn build_mana_part(params: &Params) -> Option<AbilityManaPart> {
582    let produced = params.get(keys::PRODUCED)?;
583    let mut mana_part = AbilityManaPart::new(produced, params.get(keys::RESTRICTION).unwrap_or(""));
584    mana_part.set_adds_keywords(params.get(keys::ADDS_KEYWORDS).map(str::to_string));
585    mana_part.set_triggers_when_spent(params.get(keys::TRIGGERS_WHEN_SPENT).map(str::to_string));
586    mana_part.set_persistent_mana(params.has("PersistentMana"));
587    mana_part.set_combat_mana(params.has("CombatMana"));
588    Some(mana_part)
589}
590
591fn build_mana_part_from_parsed(params: &ParsedParams<'_>) -> Option<AbilityManaPart> {
592    let produced = params.get(keys::PRODUCED)?;
593    let mut mana_part = AbilityManaPart::new(produced, params.get(keys::RESTRICTION).unwrap_or(""));
594    mana_part.set_adds_keywords(params.get(keys::ADDS_KEYWORDS).map(str::to_string));
595    mana_part.set_triggers_when_spent(params.get(keys::TRIGGERS_WHEN_SPENT).map(str::to_string));
596    mana_part.set_persistent_mana(params.has("PersistentMana"));
597    mana_part.set_combat_mana(params.has("CombatMana"));
598    Some(mana_part)
599}
600
601/// Parse the cost of an ability from its parameters.
602/// Mirrors Java's `AbilityFactory.parseAbilityCost(Card, MapOfParams, RecordType)`.
603///
604/// For AB$ (activated) and SP$ (spell) abilities, reads the Cost$ parameter
605/// and parses it into a Cost structure. Sub-abilities (DB$) have no cost.
606pub fn parse_ability_cost(
607    _host: &Card,
608    params: &Params,
609    record_type: AbilityRecordType,
610) -> Option<Cost> {
611    if record_type == AbilityRecordType::SubAbility {
612        return None;
613    }
614    params.get(keys::COST).map(parse_cost)
615}
616
617/// Adjust the change-zone target for effects that move cards between zones.
618/// Mirrors Java's `AbilityFactory.adjustChangeZoneTarget(MapOfParams, SpellAbility)`.
619///
620/// When a change-zone effect specifies `ChangeZoneTable$`, this function
621/// adjusts the target resolution to use the table mapping instead of
622/// the standard Defined$/Targeted resolution.
623pub fn adjust_change_zone_target(sa: &mut SpellAbility, game: &GameState) {
624    // If the SA has ChangeZoneTable, apply table-based targeting
625    if sa.ir.change_zone_table {
626        // The change_zone_table is populated during resolution by the effect.
627        // This function sets up the SA to use table-based targeting by
628        // ensuring the table exists.
629        if sa.change_zone_table.is_none() {
630            sa.change_zone_table = Some(crate::card::card_zone_table::CardZoneTable::default());
631        }
632    }
633
634    // Handle "Hidden" origin — if the origin zone is hidden (Library, Hand),
635    // adjust the target validation accordingly
636    if let Some(origin) = sa.ir.origin_zone {
637        let _ = game; // May need game state for validation in future
638        if matches!(
639            origin,
640            forge_foundation::ZoneType::Library | forge_foundation::ZoneType::Hand
641        ) {
642            // Hidden zones use different targeting rules
643            sa.ir.hidden = true;
644        }
645    }
646}
647
648/// Build a fused spell ability for split/fused cards.
649/// Mirrors Java's `AbilityFactory.buildFusedAbility(Card)`.
650///
651/// Fuse cards (e.g. Fire // Ice with Fuse) allow casting both halves as a
652/// single spell. This function creates a combined SpellAbility that chains
653/// the left and right halves together.
654pub fn build_fused_ability(
655    game: &GameState,
656    card_id: CardId,
657    player: PlayerId,
658) -> Option<SpellAbility> {
659    let card = game.card(card_id);
660
661    // Check if the card has the Fuse keyword
662    let has_fuse = card.keywords.contains_string_ignore_case("Fuse")
663        || card.granted_keywords.contains_string_ignore_case("Fuse");
664
665    if !has_fuse {
666        return None;
667    }
668
669    // A fused card needs at least 2 ability lines (one per half)
670    if card.abilities.len() < 2 {
671        return None;
672    }
673
674    // Build the first half
675    let host = game.card(card_id);
676    let mut left_sa = build_spell_ability_from_host_card(host, &card.abilities[0], player);
677    left_sa.source = Some(card_id);
678
679    // Build the second half and append as sub-ability
680    let right_sa = build_spell_ability_from_host_card(host, &card.abilities[1], player);
681
682    // Append right half to left half
683    let mut slot = &mut left_sa.sub_ability;
684    loop {
685        match slot {
686            Some(node) => slot = &mut node.sub_ability,
687            None => {
688                *slot = Some(Box::new(right_sa));
689                break;
690            }
691        }
692    }
693
694    // Combine the costs
695    if let (Some(left_cost), Some(right_cost)) = (
696        &left_sa.pay_costs,
697        &card.abilities.get(1).and_then(|text| {
698            let params = Params::from_raw(text);
699            params.get(keys::COST).map(parse_cost)
700        }),
701    ) {
702        let mut combined_parts = left_cost.parts.clone();
703        combined_parts.extend(right_cost.parts.clone());
704        left_sa.pay_costs = Some(Cost {
705            parts: combined_parts,
706            has_tap: left_cost.has_tap || right_cost.has_tap,
707            mandatory: false,
708        });
709    }
710
711    left_sa.description = format!("Fuse (Cast both halves of {})", card.card_name);
712
713    Some(left_sa)
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    #[test]
721    fn test_get_map_params() {
722        let input = "AB$ DealDamage | Cost$ T | NumDmg$ 1";
723        let map = get_map_params(input);
724        assert_eq!(map.get("AB").unwrap(), "DealDamage");
725        assert_eq!(map.get("Cost").unwrap(), "T");
726        assert_eq!(map.get("NumDmg").unwrap(), "1");
727    }
728
729    #[test]
730    fn test_record_type_from_params() {
731        let params = Params::from_raw("DB$ Draw | NumCards$ 2");
732        assert_eq!(
733            AbilityRecordType::from_params(&params),
734            Some(AbilityRecordType::SubAbility)
735        );
736    }
737
738    #[test]
739    fn test_record_type_from_params_static() {
740        let params = Params::from_raw("ST$ Continuous");
741        assert_eq!(
742            AbilityRecordType::from_params(&params),
743            Some(AbilityRecordType::StaticAbility)
744        );
745    }
746}