247 lines
8.3 KiB
Rust
247 lines
8.3 KiB
Rust
// 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<String>,
|
|
objective: Option<String>,
|
|
actions: Vec<ScriptAction>,
|
|
}
|
|
|
|
impl MissionScript {
|
|
pub fn new() -> MissionScript {
|
|
MissionScript { messages: Vec::new(), objective: None, actions: Vec::new() }
|
|
}
|
|
|
|
pub fn messages(&self) -> Vec<String> {
|
|
self.messages.clone()
|
|
}
|
|
|
|
pub fn objective(&self) -> Option<String> {
|
|
self.objective.clone()
|
|
}
|
|
|
|
pub fn actions(&self) -> Vec<ScriptAction> {
|
|
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<i32> {
|
|
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<EK> {
|
|
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<MissionScript, String> {
|
|
run_hook(source, "on_start", MissionScript::new())
|
|
}
|
|
|
|
pub fn run_hook(source: &str, hook: &str, initial: MissionScript) -> Result<MissionScript, String> {
|
|
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::<Dynamic>(&mut scope, &code).map_err(|e| e.to_string())?;
|
|
if let Some(returned) = returned.try_cast::<MissionScript>() {
|
|
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<Mutex<MissionScript>>) -> Engine {
|
|
let mut engine = Engine::new();
|
|
engine.set_max_operations(200_000);
|
|
engine.register_type_with_name::<MissionScript>("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());
|
|
}
|
|
}
|