409 lines
16 KiB
Rust
409 lines
16 KiB
Rust
// 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<Block>, #[serde(default)] els: Vec<Block> },
|
|
Repeat { times: Val, body: Vec<Block> },
|
|
Until { cond: Val, body: Vec<Block> }, // repeat until: checked before each run
|
|
DoUntil { cond: Val, body: Vec<Block> }, // 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<Val>, b: Box<Val> },
|
|
Math { op: String, a: Box<Val>, b: Box<Val> },
|
|
Cmp { op: String, a: Box<Val>, b: Box<Val> },
|
|
And { a: Box<Val>, b: Box<Val> },
|
|
Or { a: Box<Val>, b: Box<Val> },
|
|
Not { a: Box<Val> },
|
|
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<Val> {
|
|
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<Block> {
|
|
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<usize>,
|
|
pub key: &'static str,
|
|
}
|
|
|
|
fn check_val(v: &Val, want: Ty, n: &Names, path: &[usize], out: &mut Vec<Problem>) {
|
|
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<usize>, out: &mut Vec<Problem>) {
|
|
for (k, b) in list.iter().enumerate() {
|
|
path.push(k);
|
|
let p = |key, path: &Vec<usize>| 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<Problem> {
|
|
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<String>) {
|
|
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::<Block>(&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);
|
|
}
|
|
}
|
|
}
|