1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25pub enum AbilityRecordType {
26 Ability,
28 #[default]
30 Spell,
31 StaticAbility,
33 SubAbility,
35}
36
37impl AbilityRecordType {
38 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 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 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 pub fn get_record_type(params: &Params) -> Option<AbilityRecordType> {
96 Self::from_params(params)
97 }
98
99 pub fn api_type_of<'a>(&self, params: &'a Params) -> Option<&'a str> {
101 params.get(self.prefix())
102 }
103
104 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
112pub 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
122pub 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
206pub 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
221pub 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
234pub 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
264pub 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 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 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(¶ms_str));
312 }
313 return sa;
314 }
315
316 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 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(¶ms_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(¶ms);
451 let target_restrictions = if parsed.has(keys::VALID_TGTS) {
452 TargetRestrictions::new_from_parsed(parsed, ¶ms)
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 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
602pub 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
618pub fn adjust_change_zone_target(sa: &mut SpellAbility, game: &GameState) {
625 if sa.ir.change_zone_table {
627 if sa.change_zone_table.is_none() {
631 sa.change_zone_table = Some(crate::card::card_zone_table::CardZoneTable::default());
632 }
633 }
634
635 if let Some(origin) = sa.ir.origin_zone {
638 let _ = game; if matches!(
640 origin,
641 forge_foundation::ZoneType::Library | forge_foundation::ZoneType::Hand
642 ) {
643 sa.ir.hidden = true;
645 }
646 }
647}
648
649pub 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 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 if card.abilities.len() < 2 {
672 return None;
673 }
674
675 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 let right_sa = build_spell_ability_from_host_card(host, &card.abilities[1], player);
682
683 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 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(¶ms),
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(¶ms),
744 Some(AbilityRecordType::StaticAbility)
745 );
746 }
747}