// First Rhai mission-script runtime. This step supports log/objective/spawn hooks. // Zone/branch/gameplay hooks come in the next implementation steps. use std::sync::{Arc, Mutex}; use rhai::{Dynamic, Engine, Scope}; use crate::game::EK; #[derive(Clone, Debug, PartialEq)] pub enum ScriptAction { Spawn { side: i32, kind: String, x: f32, y: f32, count: i32, level: i32 }, SpawnInZone { zone: String, kind: String, side: i32, count: i32, level: i32 }, Wave { count: i32 }, } #[derive(Clone, Debug, PartialEq)] pub struct MissionScript { messages: Vec, objective: Option, actions: Vec, } impl MissionScript { pub fn new() -> MissionScript { MissionScript { messages: Vec::new(), objective: None, actions: Vec::new() } } pub fn messages(&self) -> Vec { self.messages.clone() } pub fn objective(&self) -> Option { self.objective.clone() } pub fn actions(&self) -> Vec { self.actions.clone() } fn merge(&mut self, other: MissionScript) { self.messages.extend(other.messages); self.actions.extend(other.actions); if other.objective.is_some() { self.objective = other.objective; } } } impl Default for MissionScript { fn default() -> Self { MissionScript::new() } } pub fn parse_side_name(name: &str) -> Option { match name.trim().to_lowercase().as_str() { "player" => Some(0), "heroes" | "hero" => Some(1), "mossgrim" => Some(2), "nightwell" => Some(3), "rival3" | "rival" => Some(4), _ => None, } } pub fn parse_spawn_kind(name: &str) -> Option { match name.trim().to_lowercase().as_str() { "worker" => Some(EK::Worker), "kobold" => Some(EK::Kobold), "spider" => Some(EK::Spider), "mage" => Some(EK::Mage), "ogre" => Some(EK::Ogre), "knight" => Some(EK::Knight), "archer" => Some(EK::Archer), "priest" => Some(EK::Priest), "dwarf" => Some(EK::Dwarf), "paladin" => Some(EK::Paladin), "jailer" => Some(EK::Jailer), "priestess" => Some(EK::Priestess), "chainbreaker" => Some(EK::Chainbreaker), "bat" => Some(EK::Bat), "gargoyle" => Some(EK::Gargoyle), "owl" => Some(EK::Owl), "wyrm" => Some(EK::Wyrm), "cogling" => Some(EK::Cogling), "revenant" => Some(EK::Revenant), "giant" => Some(EK::Giant), "fairy" => Some(EK::Fairy), "battlemage" => Some(EK::BattleMage), _ => None, } } pub fn run_start(source: &str) -> Result { run_hook(source, "on_start", MissionScript::new()) } pub fn run_hook(source: &str, hook: &str, initial: MissionScript) -> Result { let needle = format!("fn {hook}("); if !source.contains(&needle) { return Ok(initial); } let output = Arc::new(Mutex::new(initial)); let engine = script_engine(output.clone()); let code = format!("{}\n{hook}(m)", source); let mut scope = Scope::new(); scope.push("m", MissionScript::new()); let returned = engine.eval_with_scope::(&mut scope, &code).map_err(|e| e.to_string())?; if let Some(returned) = returned.try_cast::() { if !returned.messages.is_empty() || !returned.actions.is_empty() || returned.objective.is_some() { output.lock().map_err(|e| e.to_string())?.merge(returned); } } let done = output.lock().map_err(|e| e.to_string())?.clone(); Ok(done) } fn script_engine(output: Arc>) -> Engine { let mut engine = Engine::new(); engine.set_max_operations(200_000); engine.register_type_with_name::("mission"); let log_output = output.clone(); engine.register_fn("log", move |_: &mut MissionScript, s: &str| { if let Ok(mut m) = log_output.lock() { m.messages.push(s.to_string()); } }); let objective_output = output.clone(); engine.register_fn("objective", move |_: &mut MissionScript, s: &str| { if let Ok(mut m) = objective_output.lock() { m.objective = Some(s.to_string()); } }); let spawn_output = output.clone(); engine.register_fn("spawn_at", move |_: &mut MissionScript, side: i64, kind: &str, x: f64, y: f64, count: i64, level: i64| { if let Ok(mut m) = spawn_output.lock() { m.actions.push(ScriptAction::Spawn { side: side as i32, kind: kind.to_string(), x: x as f32, y: y as f32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 }); } }); let spawn_side_output = output.clone(); engine.register_fn("spawn_at", move |_: &mut MissionScript, side: &str, kind: &str, x: f64, y: f64, count: i64, level: i64| { if let Some(side) = parse_side_name(side) { if let Ok(mut m) = spawn_side_output.lock() { m.actions.push(ScriptAction::Spawn { side, kind: kind.to_string(), x: x as f32, y: y as f32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 }); } } }); let zone_output = output.clone(); engine.register_fn("spawn_count_in_zone", move |_: &mut MissionScript, zone: &str, kind: &str, count: i64, side: i64, level: i64| { if let Ok(mut m) = zone_output.lock() { m.actions.push(ScriptAction::SpawnInZone { zone: zone.to_string(), kind: kind.to_string(), side: side as i32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 }); } }); let zone_side_output = output.clone(); engine.register_fn("spawn_count_in_zone", move |_: &mut MissionScript, zone: &str, kind: &str, count: i64, side: &str, level: i64| { if let Some(side) = parse_side_name(side) { if let Ok(mut m) = zone_side_output.lock() { m.actions.push(ScriptAction::SpawnInZone { zone: zone.to_string(), kind: kind.to_string(), side, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 }); } } }); let wave_output = output.clone(); engine.register_fn("spawn_wave", move |_: &mut MissionScript, count: i64| { if let Ok(mut m) = wave_output.lock() { m.actions.push(ScriptAction::Wave { count: count.clamp(1, 20) as i32 }); } }); engine } #[cfg(test)] mod tests { use super::*; #[test] fn a_mission_script_can_log_and_change_the_objective() { let source = r#" fn on_start(m) { m.log("Die Wachstube ist offen."); m.objective("Finde drei Scherben."); } "#; let m = run_start(source).expect("the script runs"); assert_eq!(m.messages(), vec!["Die Wachstube ist offen.".to_string()]); assert_eq!(m.objective().as_deref(), Some("Finde drei Scherben.")); } #[test] fn a_mission_script_can_record_spawn_actions_in_zones() { let source = r#" fn on_start(m) { m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2); } "#; let m = run_start(source).expect("the script runs"); assert_eq!(m.actions(), vec![ScriptAction::SpawnInZone { zone: "north_gate".into(), kind: "Knight".into(), side: 1, count: 3, level: 2 }]); } #[test] fn a_mission_script_can_record_spawn_actions() { let source = r#" fn on_start(m) { m.spawn_at("HEROES", "Knight", 21.5, 21.5, 3, 2); } "#; let m = run_start(source).expect("the script runs"); assert_eq!(m.actions(), vec![ScriptAction::Spawn { side: 1, kind: "Knight".into(), x: 21.5, y: 21.5, count: 3, level: 2 }]); } #[test] fn a_mission_script_can_request_a_wave() { let source = r#" fn on_start(m) { m.spawn_wave(3); } "#; let m = run_start(source).expect("the script runs"); assert_eq!(m.actions(), vec![ScriptAction::Wave { count: 3 }]); } #[test] fn a_script_without_the_hook_is_allowed() { let m = run_hook("fn unrelated(m) { m.log('x'); }", "on_start", MissionScript::new()).expect("no hook call"); assert!(m.messages().is_empty()); } #[test] fn a_broken_script_returns_an_error() { let source = r#" fn on_start(m) { m.log("broken } "#; let error = run_start(source).expect_err("the parser rejects this script"); assert!(!error.is_empty()); } }