// Mission scripts as blocks: the block editor builds these trees, they are stored in the // campaign file and run directly from the blocks (see mscript.rs); the player never sees code. // // A script is a list of statement blocks. Values (numbers, yes/no conditions) are small // expression trees. Every block has a fixed shape, so the editor can draw and check it. use serde::{Deserialize, Serialize}; // side numbers in blocks: -1 any, 0 player, 1 heroes, 2 Mossgrim, 3 Lady Nightwell, 4 rival 3 pub const SIDES: [i32; 6] = [-1, 0, 1, 2, 3, 4]; // creature and hero kinds a block may name ("" = any) pub const KINDS: [&str; 26] = [ "Worker", "Kobold", "Spider", "Mage", "Ogre", "Jailer", "Priestess", "Chainbreaker", "Bat", "Gargoyle", "Owl", "Wyrm", "Cogling", "Revenant", "Knight", "Archer", "Priest", "Dwarf", "Paladin", "Giant", "Fairy", "BattleMage", "Ram", "Catapult", "CaveBear", "Beetle", ]; // ground a Terrain block can make pub const TERRAIN: [&str; 6] = ["floor", "earth", "rock", "water", "lava", "gold"]; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "b")] pub enum Block { Message { text: String }, Objective { text: String }, Hint { text: String }, Spawn { side: i32, kind: String, count: Val, level: Val, place: Place, #[serde(default)] name: String }, Wave { count: Val }, Gold { amount: Val }, Mana { amount: Val }, SetVar { name: String, value: Val }, AddVar { name: String, value: Val }, If { cond: Val, then: Vec, #[serde(default)] els: Vec }, Repeat { times: Val, body: Vec }, Until { cond: Val, body: Vec }, // repeat until: checked before each run DoUntil { cond: Val, body: Vec }, // repeat ... until: checked after each run Wait { secs: Val }, Win, Lose, Reveal { zone: String }, Camera { zone: String }, Terrain { zone: String, kind: String }, Kill { zone: String, side: i32 }, Trigger { name: String, on: bool }, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "p", content = "zone")] pub enum Place { Zone(String), Event, // where the unit that set off the trigger is Core, // next to the player's soul core } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "v")] pub enum Val { Num { n: f64 }, Bool { b: bool }, Var { name: String }, Gold, Mana, Time, Creatures { side: i32, kind: String }, InZone { zone: String, side: i32, kind: String }, Random { a: Box, b: Box }, Math { op: String, a: Box, b: Box }, Cmp { op: String, a: Box, b: Box }, And { a: Box, b: Box }, Or { a: Box, b: Box }, Not { a: Box }, EventKind { kind: String }, // the unit that set off the trigger is of this kind } pub const MATH_OPS: [&str; 4] = ["+", "-", "*", "/"]; pub const CMP_OPS: [&str; 6] = ["<", "<=", "=", "!=", ">=", ">"]; // what a value slot expects #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Ty { Num, Bool, } impl Val { pub fn num(n: f64) -> Val { Val::Num { n } } pub fn ty(&self) -> Ty { match self { Val::Bool { .. } | Val::Cmp { .. } | Val::And { .. } | Val::Or { .. } | Val::Not { .. } | Val::EventKind { .. } => Ty::Bool, _ => Ty::Num, } } // a fresh value of a kind (for the block palette) pub fn template(kind: &str) -> Option { let n = |x: f64| Box::new(Val::num(x)); Some(match kind { "num" => Val::num(0.0), "bool" => Val::Bool { b: true }, "var" => Val::Var { name: String::new() }, "gold" => Val::Gold, "mana" => Val::Mana, "time" => Val::Time, "creatures" => Val::Creatures { side: 0, kind: String::new() }, "inzone" => Val::InZone { zone: String::new(), side: -1, kind: String::new() }, "random" => Val::Random { a: n(1.0), b: n(6.0) }, "math" => Val::Math { op: "+".into(), a: n(0.0), b: n(1.0) }, "cmp" => Val::Cmp { op: ">".into(), a: Box::new(Val::Gold), b: n(1000.0) }, "and" => Val::And { a: Box::new(Val::Bool { b: true }), b: Box::new(Val::Bool { b: true }) }, "or" => Val::Or { a: Box::new(Val::Bool { b: true }), b: Box::new(Val::Bool { b: false }) }, "not" => Val::Not { a: Box::new(Val::Bool { b: true }) }, "eventkind" => Val::EventKind { kind: "Knight".into() }, _ => return None, }) } // the palette name of this value's kind (see template) pub fn kind_name(&self) -> &'static str { match self { Val::Num { .. } => "num", Val::Bool { .. } => "bool", Val::Var { .. } => "var", Val::Gold => "gold", Val::Mana => "mana", Val::Time => "time", Val::Creatures { .. } => "creatures", Val::InZone { .. } => "inzone", Val::Random { .. } => "random", Val::Math { .. } => "math", Val::Cmp { .. } => "cmp", Val::And { .. } => "and", Val::Or { .. } => "or", Val::Not { .. } => "not", Val::EventKind { .. } => "eventkind", } } } // value kinds by type, for the value picker pub const NUM_VALUES: [&str; 9] = ["num", "var", "gold", "mana", "time", "creatures", "inzone", "random", "math"]; pub const BOOL_VALUES: [&str; 6] = ["cmp", "and", "or", "not", "bool", "eventkind"]; // statement blocks in the palette: (category, kind) pub const PALETTE: [(&str, &str); 21] = [ ("say", "message"), ("say", "objective"), ("say", "hint"), ("units", "spawn"), ("units", "wave"), ("units", "kill"), ("world", "terrain"), ("world", "reveal"), ("world", "camera"), ("world", "gold"), ("world", "mana"), ("logic", "if"), ("logic", "repeat"), ("logic", "until"), ("logic", "dountil"), ("logic", "wait"), ("logic", "trigger"), ("vars", "setvar"), ("vars", "addvar"), ("mission", "win"), ("mission", "lose"), ]; pub const CATEGORIES: [&str; 6] = ["say", "units", "world", "logic", "vars", "mission"]; impl Block { pub fn template(kind: &str) -> Option { let n = |x: f64| Val::num(x); Some(match kind { "message" => Block::Message { text: String::new() }, "objective" => Block::Objective { text: String::new() }, "hint" => Block::Hint { text: String::new() }, "spawn" => Block::Spawn { side: 1, kind: "Knight".into(), count: n(1.0), level: n(1.0), place: Place::Core, name: String::new() }, "wave" => Block::Wave { count: n(1.0) }, "kill" => Block::Kill { zone: String::new(), side: 1 }, "terrain" => Block::Terrain { zone: String::new(), kind: "floor".into() }, "reveal" => Block::Reveal { zone: String::new() }, "camera" => Block::Camera { zone: String::new() }, "gold" => Block::Gold { amount: n(500.0) }, "mana" => Block::Mana { amount: n(100.0) }, "if" => Block::If { cond: Val::template("cmp").unwrap(), then: Vec::new(), els: Vec::new() }, "repeat" => Block::Repeat { times: n(3.0), body: Vec::new() }, "until" => Block::Until { cond: Val::template("cmp").unwrap(), body: Vec::new() }, "dountil" => Block::DoUntil { cond: Val::template("cmp").unwrap(), body: Vec::new() }, "wait" => Block::Wait { secs: n(5.0) }, "trigger" => Block::Trigger { name: String::new(), on: true }, "setvar" => Block::SetVar { name: String::new(), value: n(0.0) }, "addvar" => Block::AddVar { name: String::new(), value: n(1.0) }, "win" => Block::Win, "lose" => Block::Lose, _ => return None, }) } pub fn kind_name(&self) -> &'static str { match self { Block::Message { .. } => "message", Block::Objective { .. } => "objective", Block::Hint { .. } => "hint", Block::Spawn { .. } => "spawn", Block::Wave { .. } => "wave", Block::Kill { .. } => "kill", Block::Terrain { .. } => "terrain", Block::Reveal { .. } => "reveal", Block::Camera { .. } => "camera", Block::Gold { .. } => "gold", Block::Mana { .. } => "mana", Block::If { .. } => "if", Block::Repeat { .. } => "repeat", Block::Until { .. } => "until", Block::DoUntil { .. } => "dountil", Block::Wait { .. } => "wait", Block::Trigger { .. } => "trigger", Block::SetVar { .. } => "setvar", Block::AddVar { .. } => "addvar", Block::Win => "win", Block::Lose => "lose", } } pub fn category(&self) -> &'static str { let k = self.kind_name(); PALETTE.iter().find(|(_, b)| *b == k).map_or("logic", |(c, _)| c) } } // ------------------------------------------------------------------ checking // what a script may refer to pub struct Names<'a> { pub zones: &'a [String], pub triggers: &'a [String], pub vars: &'a [String], // variables set somewhere in the mission } // a problem in a script: the path to the block (indices into nested lists) and a language key #[derive(Clone, Debug, PartialEq)] pub struct Problem { pub path: Vec, pub key: &'static str, } fn check_val(v: &Val, want: Ty, n: &Names, path: &[usize], out: &mut Vec) { let p = |key| Problem { path: path.to_vec(), key }; if v.ty() != want { out.push(p("blk.err_type")); } match v { Val::Var { name } if name.is_empty() => out.push(p("blk.err_var")), Val::Var { name } if !n.vars.iter().any(|x| x == name) => out.push(p("blk.err_var_unset")), Val::Creatures { kind, .. } if !kind.is_empty() && !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")), Val::InZone { zone, .. } if !n.zones.iter().any(|z| z == zone) => out.push(p("blk.err_zone")), Val::EventKind { kind } if !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")), Val::Random { a, b } | Val::Math { a, b, .. } => { check_val(a, Ty::Num, n, path, out); check_val(b, Ty::Num, n, path, out); } Val::Cmp { a, b, .. } => { check_val(a, Ty::Num, n, path, out); check_val(b, Ty::Num, n, path, out); } Val::And { a, b } | Val::Or { a, b } => { check_val(a, Ty::Bool, n, path, out); check_val(b, Ty::Bool, n, path, out); } Val::Not { a } => check_val(a, Ty::Bool, n, path, out), _ => {} } } fn check_list(list: &[Block], n: &Names, path: &mut Vec, out: &mut Vec) { for (k, b) in list.iter().enumerate() { path.push(k); let p = |key, path: &Vec| Problem { path: path.clone(), key }; let zone_ok = |z: &String| n.zones.iter().any(|x| x == z); match b { Block::Message { text } | Block::Objective { text } | Block::Hint { text } if text.trim().is_empty() => out.push(p("blk.err_text", path)), Block::Spawn { kind, count, level, place, .. } => { if !KINDS.contains(&kind.as_str()) { out.push(p("blk.err_kind", path)); } check_val(count, Ty::Num, n, path, out); check_val(level, Ty::Num, n, path, out); if let Place::Zone(z) = place { if !zone_ok(z) { out.push(p("blk.err_zone", path)); } } } Block::Wave { count } => check_val(count, Ty::Num, n, path, out), Block::Gold { amount } | Block::Mana { amount } => check_val(amount, Ty::Num, n, path, out), Block::SetVar { name, value } | Block::AddVar { name, value } => { if !valid_var(name) { out.push(p("blk.err_var", path)); } check_val(value, Ty::Num, n, path, out); } Block::If { cond, then, els } => { check_val(cond, Ty::Bool, n, path, out); path.push(0); check_list(then, n, path, out); path.pop(); path.push(1); check_list(els, n, path, out); path.pop(); } Block::Repeat { times, body } => { check_val(times, Ty::Num, n, path, out); path.push(0); check_list(body, n, path, out); path.pop(); } Block::Until { cond, body } | Block::DoUntil { cond, body } => { check_val(cond, Ty::Bool, n, path, out); path.push(0); check_list(body, n, path, out); path.pop(); } Block::Wait { secs } => check_val(secs, Ty::Num, n, path, out), Block::Reveal { zone } | Block::Camera { zone } | Block::Kill { zone, .. } if !zone_ok(zone) => out.push(p("blk.err_zone", path)), Block::Terrain { zone, kind } => { if !zone_ok(zone) { out.push(p("blk.err_zone", path)); } if !TERRAIN.contains(&kind.as_str()) { out.push(p("blk.err_terrain", path)); } } Block::Trigger { name, .. } if !n.triggers.iter().any(|t| t == name) => out.push(p("blk.err_trigger", path)), _ => {} } path.pop(); } } pub fn check(list: &[Block], n: &Names) -> Vec { let mut out = Vec::new(); check_list(list, n, &mut Vec::new(), &mut out); out } // variable names: letters, digits and _, not starting with a digit pub fn valid_var(name: &str) -> bool { !name.is_empty() && name.len() <= 24 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !name.chars().next().unwrap().is_ascii_digit() } // every variable a list of blocks sets pub fn vars_set(list: &[Block], out: &mut Vec) { for b in list { match b { Block::SetVar { name, .. } | Block::AddVar { name, .. } => { if valid_var(name) && !out.contains(name) { out.push(name.clone()); } } Block::If { then, els, .. } => { vars_set(then, out); vars_set(els, out); } Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => vars_set(body, out), _ => {} } } } #[cfg(test)] mod tests { use super::*; #[test] fn checking_finds_missing_zones_vars_and_wrong_types() { let zones = vec!["gate".to_string()]; let names = Names { zones: &zones, triggers: &[], vars: &[] }; let list = vec![ Block::Reveal { zone: "nowhere".into() }, Block::If { cond: Val::num(1.0), then: vec![Block::Message { text: " ".into() }], els: vec![] }, Block::Gold { amount: Val::Var { name: "loot".into() } }, ]; let p = check(&list, &names); assert!(p.contains(&Problem { path: vec![0], key: "blk.err_zone" })); assert!(p.contains(&Problem { path: vec![1], key: "blk.err_type" })); assert!(p.contains(&Problem { path: vec![1, 0, 0], key: "blk.err_text" })); assert!(p.contains(&Problem { path: vec![2], key: "blk.err_var_unset" })); let ok = vec![Block::Reveal { zone: "gate".into() }]; assert!(check(&ok, &names).is_empty()); } #[test] fn every_palette_entry_has_a_template_and_round_trips() { for (_, k) in PALETTE { let b = Block::template(k).unwrap_or_else(|| panic!("{}", k)); assert_eq!(b.kind_name(), k); let json = serde_json::to_string(&b).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), b); } for k in NUM_VALUES.iter().chain(BOOL_VALUES.iter()) { let v = Val::template(k).unwrap(); assert_eq!(v.kind_name(), *k); } } }