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