Skip to main content

manabrew_engine/keyword/
mod.rs

1// Keyword module: infrastructure, types, and specific keyword implementations.
2// Ported from Java's `forge/game/keyword/` package.
3
4// Infrastructure
5pub mod keyword_collection;
6pub mod keyword_instance;
7pub mod keyword_interface;
8pub mod keywords_change;
9pub mod trait_keywords_change;
10
11// Type marker structs
12pub mod keyword_with_amount;
13pub mod keyword_with_cost;
14pub mod keyword_with_cost_and_amount;
15pub mod keyword_with_cost_and_type;
16pub mod keyword_with_cost_interface;
17pub mod keyword_with_type;
18pub mod keyword_with_type_interface;
19pub mod simple_keyword;
20
21// Specific keyword implementations
22pub mod amplify;
23pub mod companion;
24pub mod compleated;
25pub mod craft;
26pub mod devour;
27pub mod emerge;
28pub mod equip;
29pub mod firebending;
30pub mod hexproof;
31pub mod kicker;
32pub mod mayhem;
33pub mod modular;
34pub mod ninjutsu;
35pub mod partner;
36pub mod protection;
37pub mod suspend;
38pub mod trample;
39pub mod vanishing;
40
41// Re-export for parity: Keyword.java maps to this file.
42pub use keyword_instance::Keyword;
43
44/// Parse a keyword name string into a `Keyword` enum variant (case-insensitive).
45/// Convenience re-export of [`Keyword::smart_value_of`].
46/// Mirrors Java's `Keyword.smartValueOf(String)`.
47pub fn smart_value_of(name: &str) -> Keyword {
48    Keyword::smart_value_of(name)
49}
50
51// Keyword cost parsing utilities.
52// Mirrors Java's KeywordInterface + specific keyword parsers.
53
54/// Info about a card's kicker cost(s).
55#[derive(Debug, Clone)]
56pub struct KickerInfo {
57    /// First kicker cost string (e.g. "1 R").
58    pub cost1: String,
59    /// Optional second kicker cost string for cards with two kicker costs.
60    pub cost2: Option<String>,
61}
62
63/// Info about a card's escape cost.
64#[derive(Debug, Clone)]
65pub struct EscapeInfo {
66    /// Mana cost for escape (e.g. "3 B B").
67    pub mana_cost: String,
68    /// Number of other cards to exile from graveyard.
69    pub exile_count: i32,
70}
71
72/// Extract the cost portion from a single keyword string.
73/// E.g. "Ward:2" with name "Ward" → Some("2").
74/// Used by keyword_gen for inline cost extraction from individual keyword strings.
75pub fn extract_keyword_cost_str<'a>(kw: &'a str, name: &str) -> Option<&'a str> {
76    let prefix = format!("{name}:");
77    kw.strip_prefix(&prefix)
78}
79
80/// Parse a keyword cost from a card's keywords list.
81/// E.g. keywords contains "Flashback:2 R", name = "Flashback" -> Some("2 R")
82pub fn parse_keyword_cost(keywords: &[String], name: &str) -> Option<String> {
83    let prefix = format!("{name}:");
84    for kw in keywords {
85        if let Some(cost) = kw.strip_prefix(&prefix) {
86            return Some(cost.to_string());
87        }
88    }
89    None
90}
91
92// ── KeywordCollection-aware parsing functions ─────────────────────────
93// These operate on KeywordCollection instead of &[String], providing
94// the same parsing logic with proper separation of concerns.
95
96/// Extract a keyword cost from a KeywordCollection.
97/// E.g. collection contains "Flashback:2 R", name = "Flashback" → Some("2 R")
98pub fn extract_keyword_cost(
99    collection: &keyword_collection::KeywordCollection,
100    name: &str,
101) -> Option<String> {
102    let prefix = format!("{name}:");
103    for kw in collection.iter_strings() {
104        if let Some(cost) = kw.strip_prefix(&prefix) {
105            return Some(cost.to_string());
106        }
107    }
108    None
109}
110
111/// Extract a keyword cost from multiple collections (intrinsic + granted).
112pub fn extract_keyword_cost_from_all<'a>(
113    collections: impl IntoIterator<Item = &'a keyword_collection::KeywordCollection>,
114    name: &str,
115) -> Option<String> {
116    let prefix = format!("{name}:");
117    for coll in collections {
118        for kw in coll.iter_strings() {
119            if let Some(cost) = kw.strip_prefix(&prefix) {
120                return Some(cost.to_string());
121            }
122        }
123    }
124    None
125}
126
127/// Info about a card's suspend cost.
128#[derive(Debug, Clone)]
129pub struct SuspendInfo {
130    pub mana_cost: String,
131    pub time_counters: i32,
132}
133
134/// Parse suspend info from a KeywordCollection.
135/// Format: "Suspend:MANA_COST:TIME_COUNTERS" e.g. "Suspend:1 U:3"
136pub fn extract_suspend(collection: &keyword_collection::KeywordCollection) -> Option<SuspendInfo> {
137    for kw in collection.iter_strings() {
138        if let Some(rest) = kw.strip_prefix("Suspend:") {
139            if let Some(colon_pos) = rest.rfind(':') {
140                return Some(SuspendInfo {
141                    mana_cost: rest[..colon_pos].trim().to_string(),
142                    time_counters: rest[colon_pos + 1..].trim().parse().unwrap_or(0),
143                });
144            }
145        }
146    }
147    None
148}
149
150/// Parse escape info from a KeywordCollection.
151pub fn extract_escape(collection: &keyword_collection::KeywordCollection) -> Option<EscapeInfo> {
152    for kw in collection.iter_strings() {
153        if let Some(rest) = kw.strip_prefix("Escape:") {
154            if let Some(last_colon) = rest.rfind(':') {
155                return Some(EscapeInfo {
156                    mana_cost: rest[..last_colon].trim().to_string(),
157                    exile_count: rest[last_colon + 1..].trim().parse().unwrap_or(0),
158                });
159            }
160        }
161    }
162    None
163}
164
165/// Parse kicker info from a KeywordCollection.
166pub fn extract_kicker(collection: &keyword_collection::KeywordCollection) -> Option<KickerInfo> {
167    for kw in collection.iter_strings() {
168        if let Some(rest) = kw.strip_prefix("Kicker:") {
169            let parts: Vec<&str> = rest.splitn(2, ':').collect();
170            return Some(if parts.len() == 2 {
171                KickerInfo {
172                    cost1: parts[0].to_string(),
173                    cost2: Some(parts[1].to_string()),
174                }
175            } else {
176                KickerInfo {
177                    cost1: rest.to_string(),
178                    cost2: None,
179                }
180            });
181        }
182    }
183    None
184}
185
186/// Parse kicker info from keywords.
187/// Supports single kicker ("Kicker:1 R") and double kicker ("Kicker:1 R:1 G").
188pub fn parse_kicker(keywords: &[String]) -> Option<KickerInfo> {
189    for kw in keywords {
190        if let Some(rest) = kw.strip_prefix("Kicker:") {
191            // Check for double kicker (two costs separated by ":")
192            // E.g. "1 R:1 G"
193            let parts: Vec<&str> = rest.splitn(2, ':').collect();
194            if parts.len() == 2 {
195                return Some(KickerInfo {
196                    cost1: parts[0].to_string(),
197                    cost2: Some(parts[1].to_string()),
198                });
199            } else {
200                return Some(KickerInfo {
201                    cost1: rest.to_string(),
202                    cost2: None,
203                });
204            }
205        }
206    }
207    None
208}
209
210/// Parse escape info from keywords.
211/// Format: "Escape:MANA_COST:EXILE_COUNT" e.g. "Escape:3 B B:4"
212pub fn parse_escape(keywords: &[String]) -> Option<EscapeInfo> {
213    for kw in keywords {
214        if let Some(rest) = kw.strip_prefix("Escape:") {
215            // Split from right to find the exile count (last segment)
216            if let Some(last_colon) = rest.rfind(':') {
217                let mana_cost = &rest[..last_colon];
218                let exile_str = &rest[last_colon + 1..];
219                let exile_count = exile_str.parse::<i32>().unwrap_or(0);
220                return Some(EscapeInfo {
221                    mana_cost: mana_cost.to_string(),
222                    exile_count,
223                });
224            }
225        }
226    }
227    None
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_parse_keyword_cost() {
236        let keywords = vec!["Flashback:2 R".to_string(), "Flying".to_string()];
237        assert_eq!(
238            parse_keyword_cost(&keywords, "Flashback"),
239            Some("2 R".to_string())
240        );
241        assert_eq!(parse_keyword_cost(&keywords, "Evoke"), None);
242    }
243
244    #[test]
245    fn test_parse_kicker_single() {
246        let keywords = vec!["Kicker:1 R".to_string()];
247        let info = parse_kicker(&keywords).unwrap();
248        assert_eq!(info.cost1, "1 R");
249        assert!(info.cost2.is_none());
250    }
251
252    #[test]
253    fn test_parse_kicker_double() {
254        let keywords = vec!["Kicker:1 R:1 G".to_string()];
255        let info = parse_kicker(&keywords).unwrap();
256        assert_eq!(info.cost1, "1 R");
257        assert_eq!(info.cost2, Some("1 G".to_string()));
258    }
259
260    #[test]
261    fn test_parse_escape() {
262        let keywords = vec!["Escape:3 B B:4".to_string()];
263        let info = parse_escape(&keywords).unwrap();
264        assert_eq!(info.mana_cost, "3 B B");
265        assert_eq!(info.exile_count, 4);
266    }
267}