Skip to main content

cardinal_kernel/engine/
cards.rs

1use std::collections::{HashMap, HashSet};
2use crate::{
3    ids::CardId,
4    rules::schema::{CardDef, Ruleset},
5    model::command::{Command, StackItem, EffectRef},
6};
7
8/// Maps card IDs to their definitions for O(1) lookup during gameplay
9pub type CardRegistry = HashMap<u32, CardDef>;
10
11/// Build a card registry from card definitions without validation
12pub fn build_registry(cards: &[CardDef]) -> CardRegistry {
13    let mut registry = HashMap::new();
14    
15    for card_def in cards {
16        // Parse card ID as u32 if it's numeric, otherwise skip
17        if let Ok(card_id) = card_def.id.parse::<u32>() {
18            registry.insert(card_id, card_def.clone());
19        }
20    }
21    
22    registry
23}
24
25/// Build a card registry from card definitions with ruleset validation
26/// This validates that cards only reference keywords defined in the ruleset
27pub fn build_validated_registry(cards: &[CardDef], ruleset: &Ruleset) -> Result<CardRegistry, String> {
28    let mut registry = HashMap::new();
29    
30    // Build set of valid keyword IDs from ruleset
31    let valid_keywords: HashSet<String> = ruleset.keywords.iter()
32        .map(|k| k.id.clone())
33        .collect();
34    
35    for card_def in cards {
36        // Validate keywords - each keyword must exist in ruleset
37        for keyword in &card_def.keywords {
38            if !valid_keywords.contains(keyword) {
39                let mut sorted_keywords: Vec<_> = valid_keywords.iter().collect();
40                sorted_keywords.sort();
41                let keyword_list = if sorted_keywords.len() > 10 {
42                    format!("{:?} and {} more", &sorted_keywords[..10], sorted_keywords.len() - 10)
43                } else {
44                    format!("{}", sorted_keywords.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "))
45                };
46                
47                return Err(format!(
48                    "Card '{}' (ID: {}) references undefined keyword '{}'. Valid keywords: {}",
49                    card_def.name,
50                    card_def.id,
51                    keyword,
52                    keyword_list
53                ));
54            }
55        }
56        
57        // Parse card ID as u32 if it's numeric, otherwise skip
58        if let Ok(card_id) = card_def.id.parse::<u32>() {
59            registry.insert(card_id, card_def.clone());
60        }
61    }
62    
63    Ok(registry)
64}
65
66/// Get a card definition by ID
67pub fn get_card(registry: &CardRegistry, card_id: CardId) -> Option<&CardDef> {
68    registry.get(&card_id.0)
69}
70
71/// Generate commands from a card's abilities when an event matches a trigger
72pub fn generate_ability_commands(
73    card_id: CardId,
74    event_trigger: &str,
75    controller: crate::ids::PlayerId,
76    registry: &CardRegistry,
77    next_stack_id: &mut u32,
78) -> Vec<Command> {
79    let mut commands = Vec::new();
80    
81    if let Some(card_def) = get_card(registry, card_id) {
82        for ability in &card_def.abilities {
83            // Only fire if the trigger matches
84            if ability.trigger == event_trigger {
85                // Generate a command for this ability effect
86                if let Some(cmd) = effect_to_command(
87                    card_id,
88                    &ability.effect,
89                    &ability.params,
90                    controller,
91                    next_stack_id,
92                ) {
93                    commands.push(cmd);
94                }
95            }
96        }
97    }
98    
99    commands
100}
101
102/// Convert a card ability effect into an engine Command
103fn effect_to_command(
104    source: CardId,
105    effect_kind: &str,
106    params: &std::collections::HashMap<String, String>,
107    controller: crate::ids::PlayerId,
108    stack_id: &mut u32,
109) -> Option<Command> {
110    let id = *stack_id;
111    *stack_id += 1;
112
113    // Check if this is a scripted effect (indicated by "script:" prefix)
114    if effect_kind.starts_with("script:") {
115        let script_name = effect_kind.strip_prefix("script:").unwrap_or(effect_kind);
116        
117        // Validate script name is not empty
118        if script_name.is_empty() {
119            return None;
120        }
121        
122        return Some(Command::PushStack {
123            item: StackItem {
124                id,
125                source: Some(source),
126                controller,
127                effect: EffectRef::Scripted(script_name.to_string()),
128            },
129        });
130    }
131
132    match effect_kind {
133        "damage" => {
134            let amount = params.get("amount")
135                .and_then(|s| s.parse::<i32>().ok())
136                .unwrap_or(1);
137            
138            let effect_str = Box::leak(format!("damage_{}", amount).into_boxed_str());
139            
140            Some(Command::PushStack {
141                item: StackItem {
142                    id,
143                    source: Some(source),
144                    controller,
145                    effect: EffectRef::Builtin(effect_str),
146                },
147            })
148        }
149        "draw" => {
150            let amount = params.get("amount")
151                .and_then(|s| s.parse::<u32>().ok())
152                .unwrap_or(1);
153            
154            let effect_str = Box::leak(format!("draw_{}", amount).into_boxed_str());
155            
156            Some(Command::PushStack {
157                item: StackItem {
158                    id,
159                    source: Some(source),
160                    controller,
161                    effect: EffectRef::Builtin(effect_str),
162                },
163            })
164        }
165        "gain_life" => {
166            let amount = params.get("amount")
167                .and_then(|s| s.parse::<i32>().ok())
168                .unwrap_or(1);
169            
170            let effect_str = Box::leak(format!("gain_life_{}", amount).into_boxed_str());
171            
172            Some(Command::PushStack {
173                item: StackItem {
174                    id,
175                    source: Some(source),
176                    controller,
177                    effect: EffectRef::Builtin(effect_str),
178                },
179            })
180        }
181        "pump" => {
182            let power = params.get("power")
183                .and_then(|s| s.parse::<i32>().ok())
184                .unwrap_or(1);
185            let toughness = params.get("toughness")
186                .and_then(|s| s.parse::<i32>().ok())
187                .unwrap_or(1);
188            
189            let effect_str = Box::leak(format!("pump_{}_{}", power, toughness).into_boxed_str());
190            
191            Some(Command::PushStack {
192                item: StackItem {
193                    id,
194                    source: Some(source),
195                    controller,
196                    effect: EffectRef::Builtin(effect_str),
197                },
198            })
199        }
200        _ => {
201            // Unknown effect type - skip
202            None
203        }
204    }
205}
206
207/// Check if a card has a specific keyword
208pub fn card_has_keyword(card_def: &CardDef, keyword_id: &str) -> bool {
209    card_def.keywords.iter().any(|k| k == keyword_id)
210}
211
212/// Get a card's stat value by key
213pub fn get_card_stat<'a>(card_def: &'a CardDef, stat_key: &str) -> Option<&'a String> {
214    card_def.stats.get(stat_key)
215}
216
217/// Get a card's stat as an integer
218pub fn get_card_stat_i32(card_def: &CardDef, stat_key: &str) -> Option<i32> {
219    card_def.stats.get(stat_key)
220        .and_then(|s| s.parse::<i32>().ok())
221}
222
223/// Get a card's stat as an integer, returning Result for better error handling
224pub fn parse_card_stat_i32(card_def: &CardDef, stat_key: &str) -> Result<i32, String> {
225    match card_def.stats.get(stat_key) {
226        None => Err(format!("Stat '{}' not found on card '{}'", stat_key, card_def.name)),
227        Some(value) => value.parse::<i32>()
228            .map_err(|_| format!("Stat '{}' on card '{}' has invalid integer value: '{}'", stat_key, card_def.name, value))
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::rules::schema::{Keyword, Ruleset, GameInfo, PlayerRules, TurnStructure};
236    
237    fn minimal_ruleset() -> Ruleset {
238        Ruleset {
239            game: GameInfo {
240                id: "test".to_string(),
241                name: "Test Game".to_string(),
242                version: "1.0".to_string(),
243                description: "Test".to_string(),
244            },
245            players: PlayerRules {
246                min_players: 2,
247                max_players: 2,
248                starting_life: 20,
249                max_life: 100,
250                starting_hand_size: 5,
251                max_hand_size: 10,
252                min_deck_size: 40,
253                max_deck_size: 60,
254                mulligan_rule: "none".to_string(),
255                first_player_rule: "random".to_string(),
256            },
257            zones: vec![],
258            resources: vec![],
259            turn: TurnStructure {
260                priority_system: true,
261                skip_first_turn_draw_for_first_player: false,
262                phases: vec![],
263            },
264            actions: vec![],
265            stack: crate::rules::schema::StackRules {
266                enabled: true,
267                resolve_order: "lifo".to_string(),
268                auto_resolve_on_pass: true,
269            },
270            trigger_kinds: vec![],
271            keywords: vec![
272                Keyword {
273                    id: "flying".to_string(),
274                    name: "Flying".to_string(),
275                    description: "Can only be blocked by flying creatures".to_string(),
276                },
277                Keyword {
278                    id: "quick".to_string(),
279                    name: "Quick".to_string(),
280                    description: "Can be played at instant speed".to_string(),
281                },
282            ],
283            win_conditions: vec![],
284            loss_conditions: vec![],
285            cards: vec![],
286        }
287    }
288    
289    #[test]
290    fn test_validate_valid_keywords() {
291        let ruleset = minimal_ruleset();
292        let mut card = CardDef {
293            id: "1".to_string(),
294            name: "Test Card".to_string(),
295            card_type: "creature".to_string(),
296            cost: None,
297            description: None,
298            abilities: vec![],
299            script_path: None,
300            keywords: vec!["flying".to_string()],
301            stats: std::collections::HashMap::new(),
302        };
303        
304        let result = build_validated_registry(&[card.clone()], &ruleset);
305        assert!(result.is_ok());
306        
307        // Test with multiple valid keywords
308        card.keywords = vec!["flying".to_string(), "quick".to_string()];
309        let result = build_validated_registry(&[card], &ruleset);
310        assert!(result.is_ok());
311    }
312    
313    #[test]
314    fn test_validate_invalid_keyword() {
315        let ruleset = minimal_ruleset();
316        let card = CardDef {
317            id: "1".to_string(),
318            name: "Test Card".to_string(),
319            card_type: "creature".to_string(),
320            cost: None,
321            description: None,
322            abilities: vec![],
323            script_path: None,
324            keywords: vec!["invalid_keyword".to_string()],
325            stats: std::collections::HashMap::new(),
326        };
327        
328        let result = build_validated_registry(&[card], &ruleset);
329        assert!(result.is_err());
330        assert!(result.unwrap_err().contains("undefined keyword"));
331    }
332    
333    #[test]
334    fn test_card_has_keyword() {
335        let card = CardDef {
336            id: "1".to_string(),
337            name: "Test Card".to_string(),
338            card_type: "creature".to_string(),
339            cost: None,
340            description: None,
341            abilities: vec![],
342            script_path: None,
343            keywords: vec!["flying".to_string(), "quick".to_string()],
344            stats: std::collections::HashMap::new(),
345        };
346        
347        assert!(card_has_keyword(&card, "flying"));
348        assert!(card_has_keyword(&card, "quick"));
349        assert!(!card_has_keyword(&card, "haste"));
350    }
351    
352    #[test]
353    fn test_get_card_stats() {
354        let mut stats = std::collections::HashMap::new();
355        stats.insert("power".to_string(), "3".to_string());
356        stats.insert("toughness".to_string(), "4".to_string());
357        
358        let card = CardDef {
359            id: "1".to_string(),
360            name: "Test Creature".to_string(),
361            card_type: "creature".to_string(),
362            cost: None,
363            description: None,
364            abilities: vec![],
365            script_path: None,
366            keywords: vec![],
367            stats,
368        };
369        
370        assert_eq!(get_card_stat(&card, "power"), Some(&"3".to_string()));
371        assert_eq!(get_card_stat_i32(&card, "power"), Some(3));
372        assert_eq!(get_card_stat_i32(&card, "toughness"), Some(4));
373        assert_eq!(get_card_stat(&card, "missing"), None);
374    }
375    
376    #[test]
377    fn test_parse_card_stat_i32() {
378        let mut stats = std::collections::HashMap::new();
379        stats.insert("power".to_string(), "3".to_string());
380        stats.insert("invalid".to_string(), "not_a_number".to_string());
381        
382        let card = CardDef {
383            id: "1".to_string(),
384            name: "Test Creature".to_string(),
385            card_type: "creature".to_string(),
386            cost: None,
387            description: None,
388            abilities: vec![],
389            script_path: None,
390            keywords: vec![],
391            stats,
392        };
393        
394        // Valid stat
395        assert_eq!(parse_card_stat_i32(&card, "power"), Ok(3));
396        
397        // Missing stat
398        assert!(parse_card_stat_i32(&card, "missing").is_err());
399        assert!(parse_card_stat_i32(&card, "missing").unwrap_err().contains("not found"));
400        
401        // Invalid integer
402        assert!(parse_card_stat_i32(&card, "invalid").is_err());
403        assert!(parse_card_stat_i32(&card, "invalid").unwrap_err().contains("invalid integer value"));
404    }
405}