1use std::collections::{HashMap, HashSet};
2use crate::{
3 ids::CardId,
4 rules::schema::{CardDef, Ruleset},
5 model::command::{Command, StackItem, EffectRef},
6};
7
8pub type CardRegistry = HashMap<u32, CardDef>;
10
11pub fn build_registry(cards: &[CardDef]) -> CardRegistry {
13 let mut registry = HashMap::new();
14
15 for card_def in cards {
16 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
25pub fn build_validated_registry(cards: &[CardDef], ruleset: &Ruleset) -> Result<CardRegistry, String> {
28 let mut registry = HashMap::new();
29
30 let valid_keywords: HashSet<String> = ruleset.keywords.iter()
32 .map(|k| k.id.clone())
33 .collect();
34
35 for card_def in cards {
36 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 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
66pub fn get_card(registry: &CardRegistry, card_id: CardId) -> Option<&CardDef> {
68 registry.get(&card_id.0)
69}
70
71pub 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 if ability.trigger == event_trigger {
85 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
102fn 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 if effect_kind.starts_with("script:") {
115 let script_name = effect_kind.strip_prefix("script:").unwrap_or(effect_kind);
116
117 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 None
203 }
204 }
205}
206
207pub fn card_has_keyword(card_def: &CardDef, keyword_id: &str) -> bool {
209 card_def.keywords.iter().any(|k| k == keyword_id)
210}
211
212pub fn get_card_stat<'a>(card_def: &'a CardDef, stat_key: &str) -> Option<&'a String> {
214 card_def.stats.get(stat_key)
215}
216
217pub 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
223pub 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 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 assert_eq!(parse_card_stat_i32(&card, "power"), Ok(3));
396
397 assert!(parse_card_stat_i32(&card, "missing").is_err());
399 assert!(parse_card_stat_i32(&card, "missing").unwrap_err().contains("not found"));
400
401 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}