Files
dungeoncraft/src/blocks.rs
T

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);
}
}
}