// Mission scripts at run time: the block scripts of a mission (campaign_doc::MissionScripts) // are run directly from their blocks inside the fixed simulation step, so a co-op match runs // them identically on every computer. Every run of a script is a small "thread" (where it is // in which list of blocks); "wait" parks a thread until its time has come, and threads are // saved with the game. Loops without a wait run up to a budget of steps per check and go on at // the next check, so a script can never hang the game. use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::blocks::{Block, Place, Val}; use crate::campaign_doc::{MissionScripts, TriggerKind, ZoneDoc}; use crate::game::*; use crate::map::*; const CHECK_STEPS: u64 = 5; // triggers are checked every 5 fixed steps (0.1 s) const MAX_LOG: usize = 60; const STEP_BUDGET: u32 = 2000; // blocks a thread may run per check before it yields const MAX_THREADS: usize = 64; // pending events the simulation reports (the rest is found by comparing two checks) #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub enum ScriptEvent { Spell(String), Slap, } // a unit as the last check saw it #[derive(Clone, Debug, Serialize, Deserialize)] struct Seen { id: u32, kind: EK, side: u8, x: f32, y: f32, } // the scripts of a running mission and what they remember (saved with the game) #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct ScriptState { pub scripts: MissionScripts, pub zones: Vec, pub vars: BTreeMap, pub enabled: Vec, pub done: Vec, inside: Vec>, // zone and line triggers: units inside / on the far side sides: Vec>, // line triggers: the side of the line each unit was on last: Vec, // per trigger: last measured value or the next timer time alive: Vec, // living units at the last check towers: Vec<(u32, bool)>, // towers at the last check (id, knights') rooms: Vec, // room tiles per room kind at the last check known: usize, // tools known at the last check pub events: Vec, pub names: Vec<(String, u32)>, // units named by spawn blocks pub log: Vec<(f32, String)>, // what fired when (debug view) started: bool, next_second: f32, rng: u32, pub error: Option, #[serde(default)] threads: Vec, // scripts that are waiting (or yielded in a long loop) #[serde(skip)] pub camera: Option<(f32, f32)>, // a block asked the camera to look here (taken by main) } // which script a thread runs #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] enum Src { Start, Victory, Defeat, Tick, Trigger(usize), } // a list of blocks being worked through: its path (pairs of block index and branch below the // script's own list), the next block, and what kind of list it is #[derive(Clone, Debug, Serialize, Deserialize)] struct Frame { lp: Vec, idx: usize, left: i64, // repeat: runs still to come after this one (-1: not a repeat) loop_kind: u8, // 0 plain, 1 repeat, 2 repeat until (checked before), 3 repeat ... until (checked after) } #[derive(Clone, Debug, Serialize, Deserialize)] struct Thread { src: Src, frames: Vec, wake: f32, ev: EventInfo, } // what a running script asks for #[derive(Clone, Debug, PartialEq)] enum Act { Message(String), Objective(String), Hint(String), Spawn { side: i32, kind: String, count: i64, level: i64, place: String, zone: String, name: String }, Wave(i64), Gold(f64), Mana(f64), Win, Lose, Reveal(String), Camera(String), Terrain(String, String), Kill(String, i32), Trigger(String, bool), Weather(u8, f64), TalentXp(f64), } // the triggering unit (for "where the unit is" and "the unit is a ...") #[derive(Clone, Debug, Default, Serialize, Deserialize)] struct EventInfo { kind: Option, pos: Option<(f32, f32)>, } pub fn kind_by_name(name: &str) -> Option { let n = name.trim().to_lowercase(); crate::blocks::KINDS.iter().position(|k| k.to_lowercase() == n).map(|i| match i { 0 => EK::Worker, 1 => EK::Kobold, 2 => EK::Spider, 3 => EK::Mage, 4 => EK::Ogre, 5 => EK::Jailer, 6 => EK::Priestess, 7 => EK::Chainbreaker, 8 => EK::Bat, 9 => EK::Gargoyle, 10 => EK::Owl, 11 => EK::Wyrm, 12 => EK::Cogling, 13 => EK::Revenant, 14 => EK::Knight, 15 => EK::Archer, 16 => EK::Priest, 17 => EK::Dwarf, 18 => EK::Paladin, 19 => EK::Giant, 20 => EK::Fairy, 21 => EK::BattleMage, 22 => EK::Ram, 23 => EK::Catapult, 24 => EK::CaveBear, 25 => EK::Beetle, 26 => EK::Thief, 27 => EK::Monk, 28 => EK::Ranger, _ => EK::Inquisitor, }) } // does a unit match a side (-1 any) and a kind name ("" any)? fn matches(e_side: u8, e_kind: EK, side: i32, kind: &str) -> bool { (side < 0 || e_side as i32 == side) && (kind.is_empty() || kind_by_name(kind) == Some(e_kind)) } // ------------------------------------------------------------------ zones pub fn zone_contains(z: &ZoneDoc, x: f32, y: f32) -> bool { match z.shape.as_str() { "rect" => match (z.from, z.to) { (Some(a), Some(b)) => x >= a.0.min(b.0) && x < a.0.max(b.0) + 1.0 && y >= a.1.min(b.1) && y < a.1.max(b.1) + 1.0, _ => false, }, "tiles" => z.tiles.as_ref().map_or(false, |t| t.contains(&(x.floor() as i32, y.floor() as i32))), "line" => false, _ => { let (cx, cy) = z.at.unwrap_or((0.0, 0.0)); let r = z.radius.unwrap_or(2.0); (x - cx - 0.5).powi(2) + (y - cy - 0.5).powi(2) <= r * r } } } // the tiles of a zone (a line: the tiles it passes) pub fn zone_tiles(z: &ZoneDoc) -> Vec { let mut out = Vec::new(); if z.shape == "line" { if let (Some(a), Some(b)) = (z.from, z.to) { let n = ((b.0 - a.0).abs().max((b.1 - a.1).abs()) * 2.0).ceil().max(1.0) as usize; for k in 0..=n { let t = k as f32 / n as f32; let (x, y) = ((a.0 + (b.0 - a.0) * t + 0.5).floor() as i32, (a.1 + (b.1 - a.1) * t + 0.5).floor() as i32); if inb(x, y) && !out.contains(&idx(x, y)) { out.push(idx(x, y)); } } } return out; } if let Some(t) = z.tiles.as_ref().filter(|_| z.shape == "tiles") { return t.iter().filter(|&&(x, y)| inb(x, y)).map(|&(x, y)| idx(x, y)).collect(); } let (x0, y0, x1, y1) = match (z.shape.as_str(), z.from, z.to) { ("rect", Some(a), Some(b)) => (a.0.min(b.0) as i32, a.1.min(b.1) as i32, a.0.max(b.0) as i32, a.1.max(b.1) as i32), _ => { let (cx, cy) = z.at.unwrap_or((0.0, 0.0)); let r = z.radius.unwrap_or(2.0).ceil() as i32; (cx as i32 - r, cy as i32 - r, cx as i32 + r, cy as i32 + r) } }; for y in y0..=y1 { for x in x0..=x1 { if inb(x, y) && zone_contains(z, x as f32 + 0.5, y as f32 + 0.5) { out.push(idx(x, y)); } } } out } // the middle of a zone pub fn zone_center(z: &ZoneDoc) -> (f32, f32) { match (z.shape.as_str(), z.from, z.to, z.at) { ("rect", Some(a), Some(b), _) | ("line", Some(a), Some(b), _) => ((a.0 + b.0) * 0.5 + 0.5, (a.1 + b.1) * 0.5 + 0.5), (_, _, _, Some(c)) => (c.0 + 0.5, c.1 + 0.5), _ => { let t = zone_tiles(z); if t.is_empty() { (0.0, 0.0) } else { let (sx, sy) = t.iter().map(|&i| xy(i)).fold((0.0, 0.0), |a, (x, y)| (a.0 + x as f32 + 0.5, a.1 + y as f32 + 0.5)); (sx / t.len() as f32, sy / t.len() as f32) } } } } // which side of a line a point is on (0: beyond its ends) fn line_side(z: &ZoneDoc, x: f32, y: f32) -> i8 { let (Some(a), Some(b)) = (z.from, z.to) else { return 0 }; let (ax, ay, bx, by) = (a.0 + 0.5, a.1 + 0.5, b.0 + 0.5, b.1 + 0.5); let (dx, dy) = (bx - ax, by - ay); let len2 = dx * dx + dy * dy; if len2 <= 0.0 { return 0; } let t = ((x - ax) * dx + (y - ay) * dy) / len2; if !(-0.05..=1.05).contains(&t) { return 0; } let c = dx * (y - ay) - dy * (x - ax); if c > 0.0 { 1 } else { -1 } } // the list of blocks at a path inside a script fn list_at<'a>(s: &'a MissionScripts, src: Src, lp: &[usize]) -> Option<&'a [Block]> { let mut cur: &[Block] = match src { Src::Start => &s.start, Src::Victory => &s.victory, Src::Defeat => &s.defeat, Src::Tick => &s.tick, Src::Trigger(k) => &s.triggers.get(k)?.script, }; let mut k = 0; while k + 1 < lp.len() + 1 && k < lp.len() { let (i, branch) = (lp[k], *lp.get(k + 1)?); cur = match cur.get(i)? { Block::If { then, els, .. } => { if branch == 0 { then } else { els } } Block::Repeat { body, .. } | Block::Until { body, .. } | Block::DoUntil { body, .. } => body, _ => return None, }; k += 2; } Some(cur) } fn xorshift(rng: &mut u32) -> u32 { let mut x = (*rng).max(1); x ^= x << 13; x ^= x >> 17; x ^= x << 5; *rng = x; x } // ------------------------------------------------------------------ the game side impl ScriptState { pub fn new(scripts: MissionScripts, zones: Vec, seed: u32) -> ScriptState { let n = scripts.triggers.len(); ScriptState { enabled: scripts.triggers.iter().map(|t| t.enabled).collect(), done: vec![false; n], inside: vec![Vec::new(); n], sides: vec![Vec::new(); n], last: vec![f64::NAN; n], scripts, zones, rng: seed | 1, ..ScriptState::default() } } fn zone(&self, name: &str) -> Option<&ZoneDoc> { self.zones.iter().find(|z| z.name == name) } fn log(&mut self, t: f32, s: String) { self.log.push((t, s)); if self.log.len() > MAX_LOG { self.log.remove(0); } } } fn cmp(op: &str, a: f64, b: f64) -> bool { match op { "<" => a < b, "<=" => a <= b, "=" | "==" => (a - b).abs() < 1e-9, "!=" => (a - b).abs() >= 1e-9, ">" => a > b, _ => a >= b, } } // spell names for SpellCast triggers pub fn spell_name(s: crate::spells::Spell) -> String { format!("{:?}", s) } impl Game { // the simulation reports an event to the scripts (spells, slaps) pub(crate) fn script_event(&mut self, ev: ScriptEvent) { if let Some(s) = self.script.as_mut() { s.events.push(ev); } } // a message every player sees (scripts speak to all masters of a co-op match) fn script_msg(&mut self, key: &'static str, args: Vec) { self.msgs.push(Msg { key, args }); if self.msgs.len() > 50 { self.msgs.remove(0); } } // called from the fixed step with the player's side active pub(crate) fn run_scripts(&mut self) { if self.script.is_none() || self.state != 0 || self.sim_steps % CHECK_STEPS != 0 { return; } let mut s = self.script.take().unwrap(); let mut fire: Vec<(String, EventInfo, Option)> = Vec::new(); let mut start: Vec<(Src, EventInfo)> = Vec::new(); if !s.started { s.started = true; s.next_second = self.time + 1.0; self.remember(&mut s, true); start.push((Src::Start, EventInfo::default())); } else { self.check_triggers(&mut s, &mut fire); if self.time >= s.next_second { s.next_second += 1.0; // the checks run every second, but never twice at once (one may still be waiting) for src in [Src::Tick, Src::Victory, Src::Defeat] { if !s.threads.iter().any(|t| t.src == src) { start.push((src, EventInfo::default())); } } } } for (_, ev, trig) in fire { let Some(k) = trig else { continue }; // a trigger fires: once-triggers only the first time, switched-off ones never if !s.enabled.get(k).copied().unwrap_or(false) || (s.scripts.triggers[k].once && s.done[k]) { continue; } s.done[k] = true; let name = s.scripts.triggers[k].name.clone(); s.log(self.time, name); start.push((Src::Trigger(k), ev)); } let mut threads = std::mem::take(&mut s.threads); for (src, ev) in start { threads.push(Thread { src, frames: vec![Frame { lp: Vec::new(), idx: 0, left: -1, loop_kind: 0 }], wake: 0.0, ev }); } // run every thread whose time has come; the waiting ones stay let scripts = std::mem::take(&mut s.scripts); let mut keep = Vec::new(); for mut t in threads { if self.state == 0 && t.wake <= self.time && !self.run_thread(&mut s, &scripts, &mut t) { keep.push(t); } else if t.wake > self.time { keep.push(t); } } if keep.len() > MAX_THREADS { keep.drain(0..keep.len() - MAX_THREADS); } s.scripts = scripts; s.threads = keep; s.events.clear(); self.script = Some(s); } // runs a thread until it ends (true), waits or used up its steps (false) fn run_thread(&mut self, s: &mut ScriptState, scripts: &MissionScripts, t: &mut Thread) -> bool { let mut budget = STEP_BUDGET; loop { if self.state != 0 { return true; } if budget == 0 { t.wake = self.time; // go on at the next check return false; } budget -= 1; let Some(fr) = t.frames.last() else { return true }; let Some(list) = list_at(scripts, t.src, &fr.lp) else { return true }; let (idx, lp, left, loop_kind) = (fr.idx, fr.lp.clone(), fr.left, fr.loop_kind); if idx >= list.len() { // the end of a list: loop again, or back to the list around it let again = match loop_kind { 1 => left > 0, 2 | 3 => { // the loop's condition sits in its block in the list around it let outer = list_at(scripts, t.src, &lp[..lp.len() - 2]).and_then(|l| l.get(lp[lp.len() - 2])); match outer { Some(Block::Until { cond, .. }) | Some(Block::DoUntil { cond, .. }) => !self.cond(s, cond, &t.ev), _ => false, } } _ => false, }; if again { let fr = t.frames.last_mut().unwrap(); fr.idx = 0; if loop_kind == 1 { fr.left -= 1; } continue; } t.frames.pop(); match t.frames.last_mut() { Some(parent) => parent.idx += 1, None => return true, } continue; } let inner = |branch: usize, left: i64, kind: u8| { let mut p = lp.clone(); p.push(idx); p.push(branch); Frame { lp: p, idx: 0, left, loop_kind: kind } }; match &list[idx] { Block::If { cond, .. } => { let c = self.cond(s, cond, &t.ev); t.frames.push(inner(if c { 0 } else { 1 }, -1, 0)); } Block::Repeat { times, .. } => { let n = self.eval(s, times, &t.ev).round().clamp(0.0, 100.0) as i64; if n > 0 { t.frames.push(inner(0, n - 1, 1)); } else { t.frames.last_mut().unwrap().idx += 1; } } Block::Until { cond, .. } => { // checked before each run: already true means the inside never runs if self.cond(s, cond, &t.ev) { t.frames.last_mut().unwrap().idx += 1; } else { t.frames.push(inner(0, -1, 2)); } } Block::DoUntil { .. } => t.frames.push(inner(0, -1, 3)), Block::Wait { secs } => { let n = self.eval(s, secs, &t.ev).clamp(0.1, 36000.0) as f32; t.frames.last_mut().unwrap().idx += 1; t.wake = self.time + n; return false; } b => { let b = b.clone(); t.frames.last_mut().unwrap().idx += 1; self.exec(s, &b, &t.ev); } } } } // a value fn eval(&self, s: &mut ScriptState, v: &Val, ev: &EventInfo) -> f64 { let r = match v { Val::Num { n } => *n, Val::Bool { b } => *b as i32 as f64, Val::Var { name } => s.vars.get(name).copied().unwrap_or(0.0), Val::Gold => self.gold as f64, Val::Mana => self.mana as f64, Val::Time => self.time as f64, Val::Creatures { side, kind } => self.ents.iter().filter(|e| e.alive && e.captive == 0 && !e.chained && matches(e.side(), e.kind, *side, kind)).count() as f64, Val::InZone { zone, side, kind } => match s.zone(zone) { Some(z) => self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind) && zone_contains(z, e.x, e.y)).count() as f64, None => 0.0, }, Val::Random { a, b } => { let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev)); let (lo, hi) = (a.min(b).round(), a.max(b).round()); lo + (xorshift(&mut s.rng) as f64 % (hi - lo + 1.0)).floor() } Val::Math { op, a, b } => { let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev)); match op.as_str() { "-" => a - b, "*" => a * b, "/" => { if b == 0.0 { 0.0 } else { a / b } } _ => a + b, } } Val::Cmp { op, a, b } => { let (a, b) = (self.eval(s, a, ev), self.eval(s, b, ev)); cmp(op, a, b) as i32 as f64 } Val::And { a, b } => (self.cond(s, a, ev) && self.cond(s, b, ev)) as i32 as f64, Val::Or { a, b } => (self.cond(s, a, ev) || self.cond(s, b, ev)) as i32 as f64, Val::Not { a } => (!self.cond(s, a, ev)) as i32 as f64, Val::EventKind { kind } => (ev.kind.is_some() && ev.kind == kind_by_name(kind)) as i32 as f64, Val::WeatherIs { kind } => { // the weather felt now (a change counts once it has come in) let w = &self.weather; let now = if w.level > 0.5 { w.shown } else { crate::weather::FAIR }; (crate::blocks::WEATHER.iter().position(|k| k == kind) == Some(now as usize)) as i32 as f64 } }; if r.is_finite() { r } else { 0.0 } } fn cond(&self, s: &mut ScriptState, v: &Val, ev: &EventInfo) -> bool { self.eval(s, v, ev) != 0.0 } // one block that does something fn exec(&mut self, s: &mut ScriptState, b: &Block, ev: &EventInfo) { let act = match b { Block::Message { text } => Act::Message(text.clone()), Block::Objective { text } => Act::Objective(text.clone()), Block::Hint { text } => Act::Hint(text.clone()), Block::Spawn { side, kind, count, level, place, name } => { let (pk, pz) = match place { Place::Zone(z) => ("zone", z.clone()), Place::Event => ("event", String::new()), Place::Core => ("core", String::new()), }; Act::Spawn { side: *side, kind: kind.clone(), count: self.eval(s, count, ev).round() as i64, level: self.eval(s, level, ev).round() as i64, place: pk.into(), zone: pz, name: name.clone() } } Block::Wave { count } => Act::Wave(self.eval(s, count, ev).round() as i64), Block::Gold { amount } => Act::Gold(self.eval(s, amount, ev)), Block::Mana { amount } => Act::Mana(self.eval(s, amount, ev)), Block::Weather { kind, secs } => Act::Weather(crate::blocks::WEATHER.iter().position(|k| k == kind).unwrap_or(0) as u8, self.eval(s, secs, ev)), Block::TalentXp { amount } => Act::TalentXp(self.eval(s, amount, ev)), Block::SetVar { name, value } => { let v = self.eval(s, value, ev); s.vars.insert(name.clone(), v); return; } Block::AddVar { name, value } => { let v = s.vars.get(name).copied().unwrap_or(0.0) + self.eval(s, value, ev); s.vars.insert(name.clone(), v); return; } Block::Win => Act::Win, Block::Lose => Act::Lose, Block::Reveal { zone } => Act::Reveal(zone.clone()), Block::Camera { zone } => Act::Camera(zone.clone()), Block::Terrain { zone, kind } => Act::Terrain(zone.clone(), kind.clone()), Block::Kill { zone, side } => Act::Kill(zone.clone(), *side), Block::Trigger { name, on } => Act::Trigger(name.clone(), *on), // the flow blocks are handled by run_thread Block::If { .. } | Block::Repeat { .. } | Block::Until { .. } | Block::DoUntil { .. } | Block::Wait { .. } => return, }; self.apply_act(s, act, ev); } // what the next check compares against: living units, towers, rooms, research, zone counts fn remember(&self, s: &mut ScriptState, first: bool) { s.alive = self.ents.iter().filter(|e| e.alive && e.captive == 0).map(|e| Seen { id: e.id, kind: e.kind, side: e.side(), x: e.x, y: e.y }).collect(); s.towers = self.towers.iter().map(|t| (t.id, t.hero)).collect(); s.rooms = self.counts.to_vec(); s.known = self.known.iter().filter(|&&k| k).count(); if first { for k in 0..s.scripts.triggers.len() { let v = self.measure(s, k); s.last[k] = v; if let TriggerKind::Timer { secs, .. } = s.scripts.triggers[k].kind { s.last[k] = secs.max(0.1); } } } } // the value a trigger watches (claimed tiles, solid tiles, units in a zone, gold, ...); // conditions as 1 / 0 fn measure(&self, s: &ScriptState, k: usize) -> f64 { let player = Owner::of_side(0); match &s.scripts.triggers[k].kind { TriggerKind::ZoneClaimed { zone } => s.zone(zone).map_or(0.0, |z| zone_tiles(z).iter().filter(|&&i| self.map.t[i].owner == player).count() as f64), TriggerKind::ZoneDug { zone } => s.zone(zone).map_or(0.0, |z| zone_tiles(z).iter().filter(|&&i| self.map.t[i].solid()).count() as f64), TriggerKind::ZoneCount { zone, side, kind, op, n } => { let c = s.zone(zone).map_or(0, |z| self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind) && zone_contains(z, e.x, e.y)).count()); cmp(op, c as f64, *n) as i32 as f64 } TriggerKind::Value { what, var, op, n } => { let v = match what.as_str() { "mana" => self.mana as f64, "creatures" => self.ents.iter().filter(|e| e.alive && e.creature()).count() as f64, "heroes" => self.ents.iter().filter(|e| e.alive && e.active_hero()).count() as f64, "var" => s.vars.get(var).copied().unwrap_or(0.0), _ => self.gold as f64, }; cmp(op, v, *n) as i32 as f64 } _ => 0.0, } } fn check_triggers(&mut self, s: &mut ScriptState, fire: &mut Vec<(String, EventInfo, Option)>) { let info = |e: &Ent| EventInfo { kind: Some(e.kind), pos: Some((e.x, e.y)) }; let seen_info = |e: &Seen| EventInfo { kind: Some(e.kind), pos: Some((e.x, e.y)) }; // what changed since the last check let died: Vec = s.alive.iter().filter(|o| !self.ents.iter().any(|e| e.id == o.id && e.alive && e.captive == 0)).cloned().collect(); let fallen: Vec = s.towers.iter().filter(|(id, _)| !self.towers.iter().any(|t| t.id == *id)).map(|&(_, hero)| hero).collect(); let rooms_grown: Vec = (0..self.counts.len()).filter(|&r| self.counts[r] > s.rooms.get(r).copied().unwrap_or(0)).collect(); let researched = self.known.iter().filter(|&&k| k).count() > s.known; for k in 0..s.scripts.triggers.len() { let fn_name = format!("t_{}", k); let mut go = |ev: EventInfo| fire.push((fn_name.clone(), ev, Some(k))); let kind = s.scripts.triggers[k].kind.clone(); match &kind { TriggerKind::ZoneEnter { zone, side, kind } | TriggerKind::ZoneLeave { zone, side, kind } => { let Some(z) = s.zone(zone).cloned() else { continue }; let now: Vec = self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind) && zone_contains(&z, e.x, e.y)).map(|e| e.id).collect(); let enter = matches!(s.scripts.triggers[k].kind, TriggerKind::ZoneEnter { .. }); if enter { for e in self.ents.iter().filter(|e| now.contains(&e.id) && !s.inside[k].contains(&e.id)) { go(info(e)); } } else { for id in s.inside[k].iter().filter(|id| !now.contains(id)) { match self.ents.iter().find(|e| e.id == *id) { Some(e) => go(info(e)), None => go(EventInfo::default()), } } } s.inside[k] = now; } TriggerKind::LineCross { zone, side, kind } => { let Some(z) = s.zone(zone).cloned() else { continue }; let mut now = Vec::new(); for e in self.ents.iter().filter(|e| e.alive && !e.held && e.captive == 0 && matches(e.side(), e.kind, *side, kind)) { let sd = line_side(&z, e.x, e.y); if sd == 0 { continue; } if let Some(&(_, before)) = s.sides[k].iter().find(|(id, _)| *id == e.id) { if before != sd { go(info(e)); } } now.push((e.id, sd)); } s.sides[k] = now; } TriggerKind::ZoneClaimed { .. } | TriggerKind::ZoneDug { .. } => { let v = self.measure(s, k); let more = matches!(kind, TriggerKind::ZoneClaimed { .. }); if (more && v > s.last[k]) || (!more && v < s.last[k]) { go(EventInfo::default()); } s.last[k] = v; } TriggerKind::ZoneCount { .. } | TriggerKind::Value { .. } => { // fires when the condition becomes true let v = self.measure(s, k); if v > 0.5 && !(s.last[k] > 0.5) { go(EventInfo::default()); } s.last[k] = v; } TriggerKind::Timer { secs, every } => { if self.time as f64 >= s.last[k] { go(EventInfo::default()); s.last[k] = if *every { s.last[k] + secs.max(0.1) } else { f64::INFINITY }; } } TriggerKind::UnitDies { side, kind, name } => { for d in died.iter() { let named = name.is_empty() || s.names.iter().any(|(n, id)| n == name && *id == d.id); if named && matches(d.side, d.kind, *side, kind) { go(seen_info(d)); } } } TriggerKind::TowerFalls { side } => { for &hero in fallen.iter() { if *side < 0 || (*side == 1) == hero { go(EventInfo::default()); } } } TriggerKind::RoomBuilt { room } => { for &r in rooms_grown.iter() { if room.is_empty() || format!("{:?}", ROOM_KINDS[r]) == *room { go(EventInfo::default()); } } } TriggerKind::SpellCast { spell } => { for e in s.events.iter() { if let ScriptEvent::Spell(n) = e { if spell.is_empty() || n == spell { go(EventInfo::default()); } } } } TriggerKind::Slap => { for e in s.events.iter() { if *e == ScriptEvent::Slap { go(EventInfo::default()); } } } TriggerKind::Research => { if researched { go(EventInfo::default()); } } } } self.remember(s, false); } fn apply_act(&mut self, s: &mut ScriptState, a: Act, ev: &EventInfo) { let fill = |text: &str, vars: &BTreeMap| -> String { // {name} in a text shows the variable's value let mut out = text.to_string(); for (k, v) in vars { let shown = if v.fract() == 0.0 { format!("{}", *v as i64) } else { format!("{:.1}", v) }; out = out.replace(&format!("{{{}}}", k), &shown); } out }; match a { Act::Message(t) => { let t = fill(&t, &s.vars); self.script_msg("msg.script", vec![t]); } Act::Objective(t) => { let t = fill(&t, &s.vars); self.script_msg("msg.script_objective", vec![t.clone()]); self.objective = Some(t); } Act::Hint(t) => { let t = fill(&t, &s.vars); self.script_msg("msg.script_hint", vec![t]); self.sfx_ui(Sfx::Magic); } Act::Spawn { side, kind, count, level, place, zone, name } => { let Some(k) = kind_by_name(&kind) else { return }; let count = count.clamp(1, 20) as usize; let at: Vec<(f32, f32)> = match place.as_str() { "zone" => match s.zone(&zone) { Some(z) => { let tiles: Vec = zone_tiles(z).into_iter().filter(|&i| self.map.t[i].walkable() || (flies(k) && self.map.t[i].liquid())).collect(); (0..count).filter_map(|n| tiles.get((n * 7919 + s.rng as usize) % tiles.len().max(1)).map(|&i| (xy(i).0 as f32 + 0.5, xy(i).1 as f32 + 0.5))).collect() } None => Vec::new(), }, "event" => ev.pos.map(|p| vec![p; count]).unwrap_or_default(), _ => { let (cx, cy) = self.map.core; (0..count).map(|n| { let a = n as f32 * 1.1; (cx as f32 + 1.5 + a.cos() * 3.0, cy as f32 + 1.5 + a.sin() * 3.0) }).collect() } }; for (n, (x, y)) in at.into_iter().enumerate() { if !inb(x as i32, y as i32) { continue; } let id = self.spawn_for_side(side, k, x + (n % 3) as f32 * 0.15, y, level.clamp(1, 10) as u32); if let (Some(id), false) = (id, name.is_empty()) { s.names.push((name.clone(), id)); } } } Act::Wave(n) => { let n = n.clamp(1, 20) as u32; if let Some(m) = self.mission.as_mut() { m.waves = true; if m.wave_limit != 0 { m.wave_limit = m.wave_limit.max(self.wave.saturating_add(n)); } } self.wave_timer = 0.0; } Act::Gold(n) => { if n >= 0.0 { self.add_gold(n as f32); } else { self.gold = (self.gold + n as f32).max(0.0); } } Act::Mana(n) => self.mana = (self.mana + n as f32).clamp(0.0, self.mana_max()), // the weather comes (softly) and stays the given seconds (0: until a block changes it) Act::Weather(k, secs) => { self.weather.kind = k; self.weather.left = if secs > 0.0 { secs as f32 } else { 1.0e9 }; self.weather.strike_t = self.weather.strike_t.min(5.0); } Act::TalentXp(n) => self.add_master_xp(n.max(0.0) as f32), Act::Win => { self.state = 1; self.script_msg("msg.mission_won", vec![]); self.sfx_ui(Sfx::Victory); s.log(self.time, "win".into()); } Act::Lose => { self.state = 2; self.script_msg("msg.defeat", vec![]); self.sfx_ui(Sfx::Defeat); s.log(self.time, "lose".into()); } Act::Reveal(z) => { if let Some(z) = s.zone(&z) { for i in zone_tiles(z) { if let Some(e) = self.map.explored.get_mut(i) { *e = true; } } } } Act::Camera(z) => s.camera = s.zone(&z).map(zone_center), Act::Terrain(z, kind) => { let Some(z) = s.zone(&z).cloned() else { return }; let k = match kind.as_str() { "earth" => Kind::Earth, "rock" => Kind::Rock, "water" => Kind::Water, "lava" => Kind::Lava, "gold" => Kind::Gold, "grove" => Kind::Grove, _ => Kind::Floor, }; for i in zone_tiles(&z) { let t = self.map.t[i]; // the heart, portals, towers and the gate stay if matches!(t.kind, Kind::Core | Kind::Portal | Kind::Tower | Kind::Gate) { continue; } if k == Kind::Floor && t.solid() { self.dig_out(i); } else if k != Kind::Floor { self.torches.retain(|tr| tr.wall != i); let t = &mut self.map.t[i]; t.kind = k; t.owner = Owner::None; t.reinforced = false; t.marked = false; if k == Kind::Gold { t.gold = 800; } if t.liquid() { self.ground_lost(i); } } } self.recount(); } Act::Kill(z, side) => { let Some(z) = s.zone(&z).cloned() else { return }; for j in 0..self.ents.len() { let e = &self.ents[j]; if e.alive && !e.held && (side < 0 || e.side() as i32 == side) && zone_contains(&z, e.x, e.y) { self.ents[j].alive = false; let (x, y) = (self.ents[j].x, self.ents[j].y); self.fx(3, x, y, 0.0, 0.0, 0.7, 0xff505070); } } } Act::Trigger(name, on) => { if let Some(k) = s.scripts.triggers.iter().position(|t| t.name == name) { s.enabled[k] = on; if on { s.done[k] = false; // switched on again: it may fire once more } } } } } // spawns a unit for a side (0 player, 1 heroes, 2-4 rival masters); None when not possible pub fn spawn_for_side(&mut self, side: i32, kind: EK, x: f32, y: f32, level: u32) -> Option { let i = idx(x as i32, y as i32); if !inb(x as i32, y as i32) || !(self.map.t[i].walkable() || (flies(kind) && self.map.t[i].liquid())) { return None; } let id = self.spawn(kind, side == 1, x, y, level.max(1)); if (2..=4).contains(&side) { if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { e.side = side as u8; } } if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { e.happy = 1.0; e.settled = true; } Some(id) } } #[cfg(test)] mod tests { use super::*; use crate::blocks::{Block, Place, Val}; use crate::campaign_doc::TriggerDoc; fn rect(name: &str, a: (f32, f32), b: (f32, f32)) -> ZoneDoc { ZoneDoc { name: name.into(), shape: "rect".into(), at: None, radius: None, tiles: None, from: Some(a), to: Some(b), width: None } } fn trig(name: &str, kind: TriggerKind, script: Vec) -> TriggerDoc { TriggerDoc { name: name.into(), kind, once: true, enabled: true, script } } fn run(g: &mut Game, secs: f32) { for _ in 0..(secs * 20.0) as usize { g.update(0.05); } } #[test] fn start_script_timers_and_variables_run() { let mut g = Game::new(3, 64, 64); let scripts = MissionScripts { start: vec![Block::Message { text: "Welcome".into() }, Block::SetVar { name: "n".into(), value: Val::num(2.0) }], triggers: vec![TriggerDoc { once: false, ..trig("every", TriggerKind::Timer { secs: 1.0, every: true }, vec![Block::AddVar { name: "n".into(), value: Val::num(1.0) }]) }], ..Default::default() }; g.script = Some(ScriptState::new(scripts, vec![], 7)); run(&mut g, 0.3); assert!(g.msgs.iter().any(|m| m.key == "msg.script" && m.args[0] == "Welcome")); run(&mut g, 3.2); let n = g.script.as_ref().unwrap().vars["n"]; assert!(n >= 4.0 && n <= 6.0, "the timer fires every second: {}", n); assert!(g.script.as_ref().unwrap().error.is_none(), "{:?}", g.script.as_ref().unwrap().error); } #[test] fn weather_and_talent_blocks_work() { let mut g = Game::new(3, 64, 64); g.add_surface(3, 0.5, 2); let scripts = MissionScripts { start: vec![ Block::Weather { kind: "storm".into(), secs: Val::num(0.0) }, Block::TalentXp { amount: Val::num(400.0) }, Block::Wait { secs: Val::num(25.0) }, Block::If { cond: Val::WeatherIs { kind: "storm".into() }, then: vec![Block::SetVar { name: "storm".into(), value: Val::num(1.0) }], els: vec![] }, ], ..Default::default() }; g.script = Some(ScriptState::new(scripts, vec![], 7)); run(&mut g, 30.0); assert_eq!(g.weather.kind, crate::weather::STORM); assert!(g.weather.left > 1.0e8, "it stays"); assert!(g.talents.xp >= 400.0 && g.talents.free() >= 1); assert_eq!(g.script.as_ref().unwrap().vars.get("storm"), Some(&1.0), "the condition sees the storm"); assert!(crate::blocks::check(&g.script.as_ref().unwrap().scripts.start, &crate::blocks::Names { zones: &[], triggers: &[], vars: &[] }).iter().all(|p| p.key != "blk.err_weather")); } #[test] fn a_zone_trigger_spawns_and_the_victory_script_wins() { let mut g = Game::new(3, 64, 64); let (cx, cy) = g.map.core; let zone = rect("hall", (cx as f32 + 2.0, cy as f32 - 1.0), (cx as f32 + 4.0, cy as f32 + 1.0)); let scripts = MissionScripts { victory: vec![Block::If { cond: Val::Cmp { op: ">=".into(), a: Box::new(Val::Var { name: "seen".into() }), b: Box::new(Val::num(1.0)) }, then: vec![Block::Win], els: vec![], }], triggers: vec![trig( "enter", TriggerKind::ZoneEnter { zone: "hall".into(), side: 1, kind: String::new() }, vec![Block::SetVar { name: "seen".into(), value: Val::num(1.0) }, Block::Spawn { side: 0, kind: "Ogre".into(), count: Val::num(1.0), level: Val::num(3.0), place: Place::Event, name: "guard".into() }], )], ..Default::default() }; g.script = Some(ScriptState::new(scripts, vec![zone], 7)); run(&mut g, 0.5); let ogres = g.ents.iter().filter(|e| e.alive && e.kind == EK::Ogre).count(); // a knight walks in g.spawn(EK::Knight, true, cx as f32 + 3.5, cy as f32 + 0.5, 1); run(&mut g, 0.3); assert_eq!(g.ents.iter().filter(|e| e.alive && e.kind == EK::Ogre).count(), ogres + 1, "the trigger spawned an ogre where the knight is"); assert!(g.script.as_ref().unwrap().log.iter().any(|(_, n)| n == "enter")); run(&mut g, 1.5); assert_eq!(g.state, 1, "the victory script won"); } #[test] fn deaths_rooms_and_values_fire_their_triggers() { let mut g = Game::new(3, 64, 64); let (cx, cy) = g.map.core; let k = g.spawn(EK::Knight, true, cx as f32 + 3.5, cy as f32 + 0.5, 1); let scripts = MissionScripts { triggers: vec![ trig("dies", TriggerKind::UnitDies { side: 1, kind: "Knight".into(), name: String::new() }, vec![Block::SetVar { name: "dead".into(), value: Val::num(1.0) }]), trig("rich", TriggerKind::Value { what: "gold".into(), var: String::new(), op: ">=".into(), n: 99999.0 }, vec![Block::SetVar { name: "rich".into(), value: Val::num(1.0) }]), ], ..Default::default() }; g.script = Some(ScriptState::new(scripts, vec![], 7)); run(&mut g, 0.3); let j = g.ents.iter().position(|e| e.id == k).unwrap(); g.ents[j].alive = false; g.gold = 100000.0; run(&mut g, 0.3); let v = &g.script.as_ref().unwrap().vars; assert_eq!(v.get("dead"), Some(&1.0)); assert_eq!(v.get("rich"), Some(&1.0)); } #[test] fn waits_and_both_kinds_of_until_loops() { let mut g = Game::new(3, 64, 64); let cmp = |op: &str, a: Val, b: f64| Val::Cmp { op: op.into(), a: Box::new(a), b: Box::new(Val::num(b)) }; let var = |n: &str| Val::Var { name: n.into() }; let scripts = MissionScripts { start: vec![ Block::SetVar { name: "a".into(), value: Val::num(0.0) }, Block::SetVar { name: "pre".into(), value: Val::num(0.0) }, Block::SetVar { name: "post".into(), value: Val::num(0.0) }, // repeat until (checked before): already true, so the inside never runs Block::Until { cond: cmp(">=", var("a"), 0.0), body: vec![Block::AddVar { name: "pre".into(), value: Val::num(1.0) }] }, // repeat ... until (checked after): runs once even though it is already true Block::DoUntil { cond: cmp(">=", var("a"), 0.0), body: vec![Block::AddVar { name: "post".into(), value: Val::num(1.0) }] }, // count to three, one step every second Block::Until { cond: cmp(">=", var("a"), 3.0), body: vec![Block::Wait { secs: Val::num(1.0) }, Block::AddVar { name: "a".into(), value: Val::num(1.0) }], }, Block::SetVar { name: "done".into(), value: Val::num(1.0) }, ], ..Default::default() }; g.script = Some(ScriptState::new(scripts, vec![], 7)); run(&mut g, 0.3); let v = |g: &Game, k: &str| g.script.as_ref().unwrap().vars.get(k).copied(); assert_eq!(v(&g, "pre"), Some(0.0), "repeat until checks first"); assert_eq!(v(&g, "post"), Some(1.0), "repeat ... until runs once"); assert_eq!(v(&g, "a"), Some(0.0), "waiting"); assert_eq!(v(&g, "done"), None); run(&mut g, 1.5); assert_eq!(v(&g, "a"), Some(1.0), "one step after a second"); run(&mut g, 2.2); assert_eq!(v(&g, "a"), Some(3.0)); assert_eq!(v(&g, "done"), Some(1.0), "the script went on after the loop"); // a waiting script survives saving and loading let mut g2 = Game::new(3, 64, 64); g2.script = Some(ScriptState::new( MissionScripts { start: vec![Block::Wait { secs: Val::num(2.0) }, Block::SetVar { name: "late".into(), value: Val::num(1.0) }], ..Default::default() }, vec![], 1, )); run(&mut g2, 0.5); let json = serde_json::to_string(&g2).unwrap(); let mut g3: Game = serde_json::from_str(&json).unwrap(); g3.map.activate(); run(&mut g3, 2.0); assert_eq!(g3.script.as_ref().unwrap().vars.get("late"), Some(&1.0)); } #[test] fn an_endless_loop_does_not_hang_the_game() { let mut g = Game::new(3, 64, 64); let scripts = MissionScripts { start: vec![Block::Until { cond: Val::Bool { b: false }, body: vec![Block::AddVar { name: "n".into(), value: Val::num(1.0) }] }], ..Default::default() }; g.script = Some(ScriptState::new(scripts, vec![], 7)); run(&mut g, 1.0); let n = g.script.as_ref().unwrap().vars["n"]; assert!(n > 100.0, "the loop runs: {}", n); assert!(g.time > 0.9, "and the game goes on"); } #[test] fn zone_shapes() { let r = rect("r", (2.0, 2.0), (4.0, 3.0)); assert_eq!(zone_tiles(&r).len(), 6); assert!(zone_contains(&r, 4.9, 3.9) && !zone_contains(&r, 5.1, 3.0)); let line = ZoneDoc { name: "l".into(), shape: "line".into(), at: None, radius: None, tiles: None, from: Some((0.0, 5.0)), to: Some((10.0, 5.0)), width: None }; assert_eq!(line_side(&line, 3.0, 4.0), -line_side(&line, 3.0, 7.0)); assert_eq!(line_side(&line, 30.0, 4.0), 0, "beyond the end of the line"); } }