manabrew_engine/spellability/
valid_sa.rs1use crate::ability::api_type::ApiType;
2use crate::card::Card;
3use crate::keyword::keyword_instance::Keyword;
4use crate::spellability::SpellAbility;
5
6pub fn matches_valid_sa(
7 filter: &str,
8 sa: &SpellAbility,
9 source: &Card,
10 ability_host: Option<&Card>,
11) -> bool {
12 let filter = filter.trim();
13 if filter.is_empty() {
14 return true;
15 }
16
17 filter
18 .split(',')
19 .map(str::trim)
20 .filter(|restriction| !restriction.is_empty())
21 .any(|restriction| matches_restriction(restriction, sa, source, ability_host))
22}
23
24fn matches_restriction(
25 restriction: &str,
26 sa: &SpellAbility,
27 source: &Card,
28 ability_host: Option<&Card>,
29) -> bool {
30 let mut parts = restriction.splitn(2, '.');
31 let base = parts.next().unwrap_or("").trim();
32 let properties = parts.next();
33
34 let base_matches = matches_base_token(base, sa, ability_host);
35 if !base_matches {
36 return false;
37 }
38
39 if let Some(properties) = properties {
40 for property in properties
41 .split('+')
42 .map(str::trim)
43 .filter(|property| !property.is_empty())
44 {
45 if !matches_property_token(property, sa, source, ability_host) {
46 return false;
47 }
48 }
49 }
50
51 true
52}
53
54fn matches_base_token(token: &str, sa: &SpellAbility, ability_host: Option<&Card>) -> bool {
55 let token = token.trim();
56 let (negated, token) = match token.strip_prefix('!') {
57 Some(stripped) => (true, stripped),
58 None => (false, token),
59 };
60
61 let matched = match token.to_ascii_lowercase().as_str() {
62 "spell" => sa.is_spell,
63 "ability" => !sa.is_spell,
64 "activated" => sa.is_activated,
65 "trigger" | "triggered" => sa.is_trigger,
66 "spellability" => true,
67 "instant" => ability_host.is_some_and(|card| card.type_line.is_instant()),
68 "sorcery" => ability_host.is_some_and(|card| card.type_line.is_sorcery()),
69 _ => false,
70 };
71
72 matched != negated
73}
74
75fn matches_property_token(
76 token: &str,
77 sa: &SpellAbility,
78 source: &Card,
79 ability_host: Option<&Card>,
80) -> bool {
81 let token = token.trim();
82 let (negated, token) = match token.strip_prefix('!') {
83 Some(stripped) => (true, stripped),
84 None => (false, token),
85 };
86
87 let matched = matches_property_token_positive(token, sa, source, ability_host);
88 matched != negated
89}
90
91fn matches_property_token_positive(
92 token: &str,
93 sa: &SpellAbility,
94 source: &Card,
95 ability_host: Option<&Card>,
96) -> bool {
97 match token.to_ascii_lowercase().as_str() {
98 "self" => ability_host.is_some_and(|host| host.id == source.id),
99 "youctrl" => sa.activating_player == source.controller,
100 "oppctrl" => sa.activating_player != source.controller,
101 "manaability" => sa.is_mana_ability || sa.api == Some(ApiType::Mana),
102 "nonmanaability" => !(sa.is_mana_ability || sa.api == Some(ApiType::Mana)),
103 "istargeting" => sa.target_restrictions.is_some(),
104 "xcost" => sa.cost_has_x(),
105 "singletarget" => sa.targets_single_target(),
106 "crew" => is_crew(sa, ability_host),
107 "saddle" => is_keyword_ability(sa, ability_host, Keyword::Saddle, "saddle"),
108 "station" => is_keyword_ability(sa, ability_host, Keyword::Station, "station"),
109 "vehicle" | "mount" | "spacecraft" | "planet" => {
110 ability_host.is_some_and(|host| host.type_line.has_subtype(token))
111 }
112 _ => {
113 if sa.has_property(token) {
114 return true;
115 }
116 if sa
117 .api
118 .is_some_and(|api| api.name().eq_ignore_ascii_case(token))
119 {
120 return true;
121 }
122 ability_host.is_some_and(|host| host.has_property(token))
123 }
124 }
125}
126
127fn is_crew(sa: &SpellAbility, ability_host: Option<&Card>) -> bool {
128 if ability_text_has_keyword(sa, "crew") {
129 return true;
130 }
131
132 ability_host.is_some_and(|host| {
133 host.has_keyword_enum(Keyword::Crew)
134 && sa.api == Some(ApiType::Animate)
135 && sa.description.trim_start().starts_with("Crew")
136 })
137}
138
139fn is_keyword_ability(
140 sa: &SpellAbility,
141 ability_host: Option<&Card>,
142 keyword: Keyword,
143 text: &str,
144) -> bool {
145 if ability_text_has_keyword(sa, text) {
146 return true;
147 }
148
149 ability_host.is_some_and(|host| {
150 host.has_keyword_enum(keyword)
151 && sa
152 .description
153 .trim_start()
154 .to_ascii_lowercase()
155 .starts_with(text)
156 })
157}
158
159fn ability_text_has_keyword(sa: &SpellAbility, keyword: &str) -> bool {
160 let keyword = keyword.to_ascii_lowercase();
161 let description = sa.description.trim_start().to_ascii_lowercase();
162 let ability_text = sa.ability_text.to_ascii_lowercase();
163
164 description.starts_with(&keyword)
165 || ability_text.contains(&format!("precostdesc$ {keyword}"))
166 || ability_text.contains(&format!("spelldescription$ {keyword}"))
167}