Skip to main content

cardinal_kernel/engine/
effect_executor.rs

1use crate::{
2    ids::{CardId, PlayerId},
3    model::command::{Command, EffectRef},
4    state::gamestate::GameState,
5    engine::scripting::{RhaiEngine, ScriptContext},
6    error::CardinalError,
7};
8
9/// Execute an effect and return commands to apply its results
10/// This handles three types of effects:
11/// 1. Builtin effects (damage, draw, gain_life, pump) - parsed from effect string
12/// 2. Data-driven effects - future: loaded from TOML params
13/// 3. Scripted effects - executed via Rhai
14pub fn execute_effect(
15    effect: &EffectRef,
16    source: Option<CardId>,
17    controller: PlayerId,
18    _state: &GameState,
19    scripting: Option<&RhaiEngine>,
20) -> Result<Vec<Command>, CardinalError> {
21    match effect {
22        EffectRef::Builtin(effect_str) => execute_builtin_effect(effect_str, controller),
23        EffectRef::Scripted(script_name) => {
24            if let Some(engine) = scripting {
25                execute_scripted_effect(script_name, source, controller, engine)
26            } else {
27                Err(CardinalError(format!("Cannot execute scripted effect '{}': RhaiEngine not available", script_name)))
28            }
29        }
30    }
31}
32
33/// Execute a scripted effect via RhaiEngine
34fn execute_scripted_effect(
35    script_name: &str,
36    source: Option<CardId>,
37    controller: PlayerId,
38    engine: &RhaiEngine,
39) -> Result<Vec<Command>, CardinalError> {
40    let context = ScriptContext {
41        controller: controller.0,
42        source_card: source.map(|c| c.0).unwrap_or(0),
43    };
44    
45    let results = engine.execute_ability(script_name, context)?;
46    
47    // Convert Rhai Dynamic results into Commands
48    let mut commands = Vec::new();
49    
50    for (index, result) in results.into_iter().enumerate() {
51        // Each result must be a map with a "type" field
52        let map = result.try_cast::<rhai::Map>()
53            .ok_or_else(|| CardinalError(format!(
54                "Script '{}' returned non-map value at index {}", 
55                script_name, index
56            )))?;
57        
58        let effect_type = map.get("type")
59            .ok_or_else(|| CardinalError(format!(
60                "Script '{}' result at index {} missing 'type' field",
61                script_name, index
62            )))?
63            .clone()
64            .try_cast::<String>()
65            .ok_or_else(|| CardinalError(format!(
66                "Script '{}' result at index {} has non-string 'type' field",
67                script_name, index
68            )))?;
69        
70        match effect_type.as_str() {
71            "damage" => {
72                let target = map.get("target")
73                    .ok_or_else(|| CardinalError(format!(
74                        "Script '{}' damage effect missing 'target' field",
75                        script_name
76                    )))?
77                    .clone()
78                    .try_cast::<i32>()
79                    .ok_or_else(|| CardinalError(format!(
80                        "Script '{}' damage effect has non-integer 'target'",
81                        script_name
82                    )))?;
83                
84                let amount = map.get("amount")
85                    .ok_or_else(|| CardinalError(format!(
86                        "Script '{}' damage effect missing 'amount' field",
87                        script_name
88                    )))?
89                    .clone()
90                    .try_cast::<i32>()
91                    .ok_or_else(|| CardinalError(format!(
92                        "Script '{}' damage effect has non-integer 'amount'",
93                        script_name
94                    )))?;
95                
96                // Validate target is non-negative
97                if target < 0 {
98                    return Err(CardinalError(format!(
99                        "Script '{}' damage effect has negative target: {}",
100                        script_name, target
101                    )));
102                }
103                
104                // Validate amount is non-negative
105                if amount < 0 {
106                    return Err(CardinalError(format!(
107                        "Script '{}' damage effect has negative amount: {}",
108                        script_name, amount
109                    )));
110                }
111                
112                // Validate target fits in u8 range
113                if target > u8::MAX as i32 {
114                    return Err(CardinalError(format!(
115                        "Script '{}' damage effect has target out of range: {}",
116                        script_name, target
117                    )));
118                }
119                
120                commands.push(Command::ChangeLife {
121                    player: PlayerId(target as u8),
122                    delta: -amount,
123                });
124            }
125            "draw" => {
126                // TODO: Implement card drawing
127                // For now, validate fields exist but don't execute
128                let _player = map.get("player")
129                    .ok_or_else(|| CardinalError(format!(
130                        "Script '{}' draw effect missing 'player' field",
131                        script_name
132                    )))?;
133                let _count = map.get("count")
134                    .ok_or_else(|| CardinalError(format!(
135                        "Script '{}' draw effect missing 'count' field",
136                        script_name
137                    )))?;
138            }
139            "gain_life" => {
140                let player = map.get("player")
141                    .ok_or_else(|| CardinalError(format!(
142                        "Script '{}' gain_life effect missing 'player' field",
143                        script_name
144                    )))?
145                    .clone()
146                    .try_cast::<i32>()
147                    .ok_or_else(|| CardinalError(format!(
148                        "Script '{}' gain_life effect has non-integer 'player'",
149                        script_name
150                    )))?;
151                
152                let amount = map.get("amount")
153                    .ok_or_else(|| CardinalError(format!(
154                        "Script '{}' gain_life effect missing 'amount' field",
155                        script_name
156                    )))?
157                    .clone()
158                    .try_cast::<i32>()
159                    .ok_or_else(|| CardinalError(format!(
160                        "Script '{}' gain_life effect has non-integer 'amount'",
161                        script_name
162                    )))?;
163                
164                // Validate player is non-negative
165                if player < 0 {
166                    return Err(CardinalError(format!(
167                        "Script '{}' gain_life effect has negative player: {}",
168                        script_name, player
169                    )));
170                }
171                
172                // Validate amount is non-negative
173                if amount < 0 {
174                    return Err(CardinalError(format!(
175                        "Script '{}' gain_life effect has negative amount: {}",
176                        script_name, amount
177                    )));
178                }
179                
180                // Validate player fits in u8 range
181                if player > u8::MAX as i32 {
182                    return Err(CardinalError(format!(
183                        "Script '{}' gain_life effect has player out of range: {}",
184                        script_name, player
185                    )));
186                }
187                
188                commands.push(Command::ChangeLife {
189                    player: PlayerId(player as u8),
190                    delta: amount,
191                });
192            }
193            "pump" => {
194                // TODO: Implement creature stat modification
195                // For now, validate fields exist but don't execute
196                let _card = map.get("card")
197                    .ok_or_else(|| CardinalError(format!(
198                        "Script '{}' pump effect missing 'card' field",
199                        script_name
200                    )))?;
201                let _power = map.get("power")
202                    .ok_or_else(|| CardinalError(format!(
203                        "Script '{}' pump effect missing 'power' field",
204                        script_name
205                    )))?;
206                let _toughness = map.get("toughness")
207                    .ok_or_else(|| CardinalError(format!(
208                        "Script '{}' pump effect missing 'toughness' field",
209                        script_name
210                    )))?;
211            }
212            _ => {
213                return Err(CardinalError(format!(
214                    "Script '{}' has unknown effect type: '{}'",
215                    script_name, effect_type
216                )));
217            }
218        }
219    }
220    
221    Ok(commands)
222}
223
224/// Execute a builtin effect parsed from its string representation
225/// Format: "{effect_type}_{param1}_{param2}..."
226/// Examples: "damage_2", "draw_1", "gain_life_3", "pump_1_1"
227fn execute_builtin_effect(effect_str: &str, controller: PlayerId) -> Result<Vec<Command>, CardinalError> {
228    // Handle different effect patterns
229    if effect_str.starts_with("damage_") {
230        let amount = effect_str.strip_prefix("damage_")
231            .and_then(|s| s.parse::<i32>().ok())
232            .ok_or_else(|| CardinalError(format!("Invalid damage amount in: {}", effect_str)))?;
233        
234        // Validate amount is non-negative to prevent healing via damage
235        if amount < 0 {
236            return Err(CardinalError(format!(
237                "Builtin damage effect has negative amount: {} (effect: {})",
238                amount, effect_str
239            )));
240        }
241        
242        // TODO: Add proper target selection
243        // For now, damage affects the controller as a placeholder
244        // Future: request target via PendingChoice, then apply to selected target
245        Ok(vec![Command::ChangeLife {
246            player: controller,
247            delta: -amount,
248        }])
249    } else if effect_str.starts_with("draw_") {
250        let count = effect_str.strip_prefix("draw_")
251            .and_then(|s| s.parse::<u32>().ok())
252            .ok_or_else(|| CardinalError(format!("Invalid draw count in: {}", effect_str)))?;
253        
254        // Validate count is reasonable (prevent excessive draws)
255        if count == 0 {
256            return Err(CardinalError(format!(
257                "Builtin draw effect has zero count (effect: {})",
258                effect_str
259            )));
260        }
261        
262        // TODO: Implement card drawing
263        // For now, return empty (no MoveCard commands yet)
264        Ok(vec![])
265    } else if effect_str.starts_with("gain_life_") {
266        let amount = effect_str.strip_prefix("gain_life_")
267            .and_then(|s| s.parse::<i32>().ok())
268            .ok_or_else(|| CardinalError(format!("Invalid life amount in: {}", effect_str)))?;
269        
270        // Validate amount is non-negative to prevent damage via life gain
271        if amount < 0 {
272            return Err(CardinalError(format!(
273                "Builtin gain_life effect has negative amount: {} (effect: {})",
274                amount, effect_str
275            )));
276        }
277        
278        Ok(vec![Command::ChangeLife {
279            player: controller,
280            delta: amount,
281        }])
282    } else if effect_str.starts_with("pump_") {
283        let parts: Vec<&str> = effect_str.strip_prefix("pump_")
284            .unwrap_or("")
285            .split('_')
286            .collect();
287        
288        let _power = parts.get(0)
289            .and_then(|s| s.parse::<i32>().ok())
290            .ok_or_else(|| CardinalError(format!("Invalid power in: {}", effect_str)))?;
291        let _toughness = parts.get(1)
292            .and_then(|s| s.parse::<i32>().ok())
293            .ok_or_else(|| CardinalError(format!("Invalid toughness in: {}", effect_str)))?;
294        
295        // Note: pump can have negative values to reduce stats, so no validation here
296        
297        // TODO: Implement creature stat modification
298        // For now, return empty (no creature tracking yet)
299        Ok(vec![])
300    } else {
301        Err(CardinalError(format!("Unknown builtin effect type: {}", effect_str)))
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::state::gamestate::{GameState, TurnState, PlayerState};
309    use crate::ids::{PhaseId, StepId};
310    
311    fn minimal_game_state() -> GameState {
312        GameState {
313            turn: TurnState {
314                number: 1,
315                active_player: PlayerId(0),
316                priority_player: PlayerId(0),
317                phase: PhaseId("main"),
318                step: StepId("main"),
319                priority_passes: 0,
320            },
321            players: vec![
322                PlayerState { id: PlayerId(0), life: 20 },
323                PlayerState { id: PlayerId(1), life: 20 },
324            ],
325            zones: vec![],
326            stack: vec![],
327            pending_choice: None,
328            ended: None,
329        }
330    }
331    
332    #[test]
333    fn test_execute_damage_effect() {
334        let effect = EffectRef::Builtin("damage_2");
335        let controller = PlayerId(0);
336        let state = minimal_game_state();
337        
338        let result = execute_effect(&effect, None, controller, &state, None);
339        assert!(result.is_ok());
340        
341        let commands = result.unwrap();
342        assert_eq!(commands.len(), 1);
343        
344        match &commands[0] {
345            Command::ChangeLife { player, delta } => {
346                assert_eq!(*player, controller);
347                assert_eq!(*delta, -2);
348            }
349            _ => panic!("Expected ChangeLife command"),
350        }
351    }
352    
353    #[test]
354    fn test_execute_gain_life_effect() {
355        let effect = EffectRef::Builtin("gain_life_5");
356        let controller = PlayerId(0);
357        let state = minimal_game_state();
358        
359        let result = execute_effect(&effect, None, controller, &state, None);
360        if result.is_err() {
361            println!("Error: {:?}", result.as_ref().err());
362        }
363        assert!(result.is_ok());
364        
365        let commands = result.unwrap();
366        assert_eq!(commands.len(), 1);
367        
368        match &commands[0] {
369            Command::ChangeLife { player, delta } => {
370                assert_eq!(*player, controller);
371                assert_eq!(*delta, 5);
372            }
373            _ => panic!("Expected ChangeLife command"),
374        }
375    }
376    
377    #[test]
378    fn test_execute_draw_effect() {
379        let effect = EffectRef::Builtin("draw_1");
380        let controller = PlayerId(0);
381        let state = minimal_game_state();
382        
383        let result = execute_effect(&effect, None, controller, &state, None);
384        assert!(result.is_ok());
385        
386        // Draw not yet implemented, should return empty
387        let commands = result.unwrap();
388        assert_eq!(commands.len(), 0);
389    }
390    
391    #[test]
392    fn test_execute_pump_effect() {
393        let effect = EffectRef::Builtin("pump_1_1");
394        let controller = PlayerId(0);
395        let state = minimal_game_state();
396        
397        let result = execute_effect(&effect, None, controller, &state, None);
398        assert!(result.is_ok());
399        
400        // Pump not yet implemented, should return empty
401        let commands = result.unwrap();
402        assert_eq!(commands.len(), 0);
403    }
404    
405    #[test]
406    fn test_invalid_effect_string() {
407        let effect = EffectRef::Builtin("invalid");
408        let controller = PlayerId(0);
409        let state = minimal_game_state();
410        
411        let result = execute_effect(&effect, None, controller, &state, None);
412        assert!(result.is_err());
413    }
414    
415    #[test]
416    fn test_invalid_damage_amount() {
417        let effect = EffectRef::Builtin("damage_abc");
418        let controller = PlayerId(0);
419        let state = minimal_game_state();
420        
421        let result = execute_effect(&effect, None, controller, &state, None);
422        assert!(result.is_err());
423    }
424    
425    #[test]
426    fn test_execute_scripted_effect() {
427        use crate::engine::scripting::RhaiEngine;
428        
429        let mut engine = RhaiEngine::new();
430        let script = r#"
431            fn execute_ability() {
432                gain_life(0, 3)
433            }
434        "#;
435        
436        engine.register_script("test_card".to_string(), script).unwrap();
437        
438        let effect = EffectRef::Scripted("test_card".to_string());
439        let controller = PlayerId(0);
440        let state = minimal_game_state();
441        
442        let result = execute_effect(&effect, None, controller, &state, Some(&engine));
443        assert!(result.is_ok());
444        
445        let commands = result.unwrap();
446        assert_eq!(commands.len(), 1);
447        
448        match &commands[0] {
449            Command::ChangeLife { player, delta } => {
450                assert_eq!(*player, PlayerId(0));
451                assert_eq!(*delta, 3);
452            }
453            _ => panic!("Expected ChangeLife command"),
454        }
455    }
456    
457    #[test]
458    fn test_execute_scripted_damage_effect() {
459        use crate::engine::scripting::RhaiEngine;
460        
461        let mut engine = RhaiEngine::new();
462        let script = r#"
463            fn execute_ability() {
464                deal_damage(1, 5)
465            }
466        "#;
467        
468        engine.register_script("bolt_card".to_string(), script).unwrap();
469        
470        let effect = EffectRef::Scripted("bolt_card".to_string());
471        let controller = PlayerId(0);
472        let state = minimal_game_state();
473        
474        let result = execute_effect(&effect, None, controller, &state, Some(&engine));
475        assert!(result.is_ok());
476        
477        let commands = result.unwrap();
478        assert_eq!(commands.len(), 1);
479        
480        match &commands[0] {
481            Command::ChangeLife { player, delta } => {
482                assert_eq!(*player, PlayerId(1));
483                assert_eq!(*delta, -5);
484            }
485            _ => panic!("Expected ChangeLife command"),
486        }
487    }
488}