Files
dungeoncraft/src/mscript.rs
T
Richard ZinkandClaude Opus 5.5 d17668f3b2 Wait and both kinds of until loops; blocks run directly
Script runs are resumable threads saved with the game; Rhai is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:57:36 +01:00

1082 lines
45 KiB
Rust

// 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<ZoneDoc>,
pub vars: BTreeMap<String, f64>,
pub enabled: Vec<bool>,
pub done: Vec<bool>,
inside: Vec<Vec<u32>>, // zone and line triggers: units inside / on the far side
sides: Vec<Vec<(u32, i8)>>, // line triggers: the side of the line each unit was on
last: Vec<f64>, // per trigger: last measured value or the next timer time
alive: Vec<Seen>, // living units at the last check
towers: Vec<(u32, bool)>, // towers at the last check (id, knights')
rooms: Vec<usize>, // room tiles per room kind at the last check
known: usize, // tools known at the last check
pub events: Vec<ScriptEvent>,
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<String>,
#[serde(default)]
threads: Vec<Thread>, // 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<usize>,
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<Frame>,
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),
}
// the triggering unit (for "where the unit is" and "the unit is a ...")
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct EventInfo {
kind: Option<EK>,
pos: Option<(f32, f32)>,
}
pub fn kind_by_name(name: &str) -> Option<EK> {
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,
_ => EK::BattleMage,
})
}
// 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<usize> {
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<ZoneDoc>, 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<String>) {
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<usize>)> = 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,
};
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::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<usize>)>) {
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<Seen> = s.alive.iter().filter(|o| !self.ents.iter().any(|e| e.id == o.id && e.alive && e.captive == 0)).cloned().collect();
let fallen: Vec<bool> = s.towers.iter().filter(|(id, _)| !self.towers.iter().any(|t| t.id == *id)).map(|&(_, hero)| hero).collect();
let rooms_grown: Vec<usize> = (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<u32> = 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, f64>| -> 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<usize> = 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()),
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,
_ => 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<u32> {
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<Block>) -> 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 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");
}
}