Skip to main content

nightshade_api/
scripting.rs

1//! Rhai scripting as an api-layer binding to the command and event surface.
2//!
3//! The runtime lives here, next to the [`Command`](crate::Command) enum and the
4//! other language bindings, not in the engine. A script produces typed `Command`
5//! values, read straight from the rhai value with no json step. Each tick the
6//! runner reads the engine's frozen events, runs every enabled script, and
7//! submits the commands they produced as one deferred batch.
8//!
9//! A script can ask for a command's result back: `commands.tag("id")` after a
10//! command marks it, and next tick the script reads the reply (a spawned entity,
11//! a raycast hit, a query value) from the `replies` map. This keeps the boundary
12//! pure data, results flow back as values the script reads, never a live world.
13//!
14//! The driver, an app or the editor worker, owns the [`ScriptRuntime`] and calls
15//! [`run_scripts`] once a tick after the event swap.
16
17use nightshade::ecs::world::CORE;
18use std::collections::{HashMap, HashSet};
19
20use rhai::{AST, Array, Dynamic, Engine, Map, Scope};
21
22use nightshade::ecs::event::{Event, events};
23use nightshade::ecs::script::components::ScriptSource;
24use nightshade::ecs::world::SCRIPT;
25use nightshade::prelude::{Entity, KeyCode, MouseState, Vec3, World, tracing};
26
27use crate::command::{Command, CommandReply, submit_commands};
28
29/// The reserved keys a `commands.tag(id)` wrapper uses, so the collector can tell
30/// a tagged request from a plain command map. A command variant is PascalCase,
31/// so these never collide.
32const TAG_ID_KEY: &str = "$id";
33const TAG_COMMAND_KEY: &str = "$cmd";
34
35/// The write policy gating which commands a script may produce. The default
36/// allows every command. Restrict it to a named subset to sandbox an untrusted
37/// or agent-authored script to part of the command surface, enforced at the
38/// command boundary where the script's whole effect already passes.
39#[derive(Clone, Default)]
40pub enum CommandPolicy {
41    #[default]
42    AllowAll,
43    Allow(HashSet<String>),
44}
45
46impl CommandPolicy {
47    fn allows(&self, name: &str) -> bool {
48        match self {
49            CommandPolicy::AllowAll => true,
50            CommandPolicy::Allow(allowed) => allowed.contains(name),
51        }
52    }
53}
54
55/// The scripting runtime: one rhai engine, a compiled-AST cache keyed by source
56/// so identical scripts compile once, a persistent scope per entity so script
57/// state survives across ticks, and a write policy bounding the command surface.
58pub struct ScriptRuntime {
59    pub enabled: bool,
60    pub policy: CommandPolicy,
61    engine: Engine,
62    modules: ScriptModules,
63    compiled: HashMap<u64, AST>,
64    scopes: HashMap<Entity, Scope<'static>>,
65    global_scopes: HashMap<String, Scope<'static>>,
66    started_globals: HashSet<String>,
67    started_entities: HashSet<Entity>,
68    next_replies: Map,
69    assets: HashMap<String, Vec<u8>>,
70    random_state: u64,
71}
72
73/// The starting seed for a runtime's random sequence, reset on
74/// [`script_runtime_reset`] so reloading a script replays the same rolls.
75const RANDOM_SEED: u64 = 0x853c_49e6_748f_ea9b;
76
77/// A resolver for `import "name" as alias;`, backed by a shared table of compiled
78/// modules. Seeded with the bundled `std/*` library and extended at runtime with a
79/// game's own reusable scripts through [`script_runtime_register_module`], so
80/// scripts import helpers instead of copying them between files.
81#[derive(Clone, Default)]
82struct ScriptModules {
83    table: std::sync::Arc<
84        std::sync::RwLock<std::collections::BTreeMap<String, rhai::Shared<rhai::Module>>>,
85    >,
86}
87
88impl ScriptModules {
89    fn insert(&self, name: &str, module: rhai::Module) {
90        if let Ok(mut table) = self.table.write() {
91            table.insert(name.to_string(), rhai::Shared::new(module));
92        }
93    }
94}
95
96impl rhai::ModuleResolver for ScriptModules {
97    fn resolve(
98        &self,
99        _engine: &Engine,
100        _source: Option<&str>,
101        path: &str,
102        position: rhai::Position,
103    ) -> Result<rhai::Shared<rhai::Module>, Box<rhai::EvalAltResult>> {
104        let not_found = || {
105            Box::new(rhai::EvalAltResult::ErrorModuleNotFound(
106                path.to_string(),
107                position,
108            ))
109        };
110        match self.table.read() {
111            Ok(table) => table.get(path).cloned().ok_or_else(not_found),
112            Err(_) => Err(not_found()),
113        }
114    }
115}
116
117/// The bundled standard library: reusable helper scripts a game imports with
118/// `import "std/ease" as ease;`. Pure functions only, since a script's functions
119/// cannot reach the per-tick scope, so these compose math and the global helpers.
120const STD_MODULES: &[(&str, &str)] = &[
121    ("std/ease", include_str!("std_scripts/ease.rhai")),
122    ("std/math", include_str!("std_scripts/math.rhai")),
123    ("std/vec", include_str!("std_scripts/vec.rhai")),
124    ("std/timer", include_str!("std_scripts/timer.rhai")),
125];
126
127/// Compiles each bundled std module and installs it in the resolver table.
128fn seed_std_modules(engine: &Engine, modules: &ScriptModules) {
129    for (name, source) in STD_MODULES {
130        match engine.compile(*source) {
131            Ok(ast) => match rhai::Module::eval_ast_as_new(Scope::new(), &ast, engine) {
132                Ok(module) => modules.insert(name, module),
133                Err(error) => tracing::error!("std module {name} failed to build: {error}"),
134            },
135            Err(error) => tracing::error!("std module {name} failed to parse: {error}"),
136        }
137    }
138}
139
140/// Builds the scripting engine with the playground's limits and the full command
141/// vocabulary registered. Shared so [`script_check`] validates against the exact
142/// engine the runtime runs, not a bare default with a tighter expression depth.
143/// The `modules` table backs `import`, seeded here with the std library.
144fn new_engine(modules: &ScriptModules) -> Engine {
145    let mut engine = Engine::new();
146    engine.set_max_operations(200_000);
147    engine.set_max_call_levels(64);
148    engine.set_max_string_size(8 * 1024);
149    engine.set_max_array_size(4096);
150    engine.set_max_expr_depths(0, 0);
151    register_api(&mut engine);
152    engine.set_module_resolver(modules.clone());
153    seed_std_modules(&engine, modules);
154    engine
155}
156
157impl Default for ScriptRuntime {
158    fn default() -> Self {
159        let modules = ScriptModules::default();
160        let engine = new_engine(&modules);
161        Self {
162            enabled: false,
163            policy: CommandPolicy::AllowAll,
164            engine,
165            modules,
166            compiled: HashMap::new(),
167            scopes: HashMap::new(),
168            global_scopes: HashMap::new(),
169            started_globals: HashSet::new(),
170            started_entities: HashSet::new(),
171            next_replies: Map::new(),
172            assets: HashMap::new(),
173            random_state: RANDOM_SEED,
174        }
175    }
176}
177
178/// What one tick of scripting produced: the commands submitted this tick, so a
179/// tool can show the traffic, any warnings, so a driver can surface compile
180/// errors, `on_tick` failures, and dropped or blocked commands to the user, and
181/// the lines scripts wrote with `log`, in call order, so a console can show them.
182#[derive(Default)]
183pub struct ScriptReport {
184    pub commands: Vec<Command>,
185    pub errors: Vec<String>,
186    pub logs: Vec<String>,
187}
188
189thread_local! {
190    /// Per-tick scratch register for the random generator. The canonical seed
191    /// lives in [`ScriptRuntime::random_state`]; [`run_scripts`] loads it here
192    /// before a tick and reads it back after, so the registered `random` helpers
193    /// (which the engine builds once and cannot reach the runtime) advance the
194    /// runtime's own state rather than a hidden global.
195    static SCRIPT_RNG: std::cell::Cell<u64> = const { std::cell::Cell::new(RANDOM_SEED) };
196
197    /// Per-tick sink for script `log` output. The engine is built once and its
198    /// `log` closure cannot reach the runtime, so it appends here; [`run_scripts`]
199    /// clears this before a tick and drains it into the [`ScriptReport`] after, the
200    /// same indirection [`SCRIPT_RNG`] uses for randomness.
201    static SCRIPT_LOGS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
202}
203
204/// The next pseudo-random float in `[0, 1)` from the script runtime's xorshift
205/// generator, so scripts get randomness without hand-rolling a generator.
206fn next_unit_random() -> f64 {
207    SCRIPT_RNG.with(|cell| {
208        let mut state = cell.get();
209        state ^= state << 13;
210        state ^= state >> 7;
211        state ^= state << 17;
212        cell.set(state);
213        ((state >> 11) as f64) / ((1u64 << 53) as f64)
214    })
215}
216
217/// Registers the read-only helpers a script always has, then every command as a
218/// method on the `commands` array. A script targets the whole [`Command`]
219/// surface two equivalent ways: a terse method, `commands.spawn_cube([x, y,
220/// z])`, or the map shape, `commands.push(#{ SpawnCube: #{ position: [...] }
221/// })`. `entity_ref` turns a script's packed entity handle into a
222/// `Ref::Existing` so a command can name a live entity by id.
223fn register_api(engine: &mut Engine) {
224    engine.register_fn("log", |message: &str| {
225        tracing::info!("[script] {message}");
226        SCRIPT_LOGS.with(|logs| logs.borrow_mut().push(message.to_string()));
227    });
228    // Randomness without a hand-rolled generator: `random()` in [0, 1),
229    // `random_range(lo, hi)` for a float, `random_int(lo, hi)` inclusive.
230    engine.register_fn("random", next_unit_random);
231    engine.register_fn("random_range", |low: f64, high: f64| {
232        low + next_unit_random() * (high - low)
233    });
234    engine.register_fn("random_int", |low: i64, high: i64| {
235        if high <= low {
236            low
237        } else {
238            low + (next_unit_random() * ((high - low + 1) as f64)) as i64
239        }
240    });
241    register_mixed_number_ops(engine);
242    register_color_helpers(engine);
243    register_vector_helpers(engine);
244    engine.register_fn("entity_ref", |packed: i64| {
245        let entity = script_to_entity(packed);
246        let mut map = Map::new();
247        map.insert("Existing".into(), Dynamic::from(entity.id as i64));
248        Dynamic::from_map(map)
249    });
250    // References the entity made by an earlier command in this frame's batch by
251    // its position, so `commands.set_color(result(0), ...)` configures the
252    // entity command 0 spawned. Sugar for the `#{ Result: index }` shape.
253    engine.register_fn("result", |index: i64| {
254        let mut map = Map::new();
255        map.insert("Result".into(), Dynamic::from(index));
256        Dynamic::from_map(map)
257    });
258    // References whatever the most recent command produced, for the common
259    // spawn-then-configure pattern inside a loop: `commands.spawn_cube(p);
260    // commands.set_color(commands.last(), c)`.
261    engine.register_fn("last", |commands: &mut Array| {
262        let index = commands.len().saturating_sub(1) as i64;
263        let mut map = Map::new();
264        map.insert("Result".into(), Dynamic::from(index));
265        Dynamic::from_map(map)
266    });
267    // Marks the command just pushed so its result is reported back next frame
268    // under `id` in `replies`. This is how a script reads a query (a raycast hit,
269    // a spawned entity, a bounds box): `commands.raycast(...); commands.tag("hit")`
270    // this frame, then `replies.hit` next frame.
271    engine.register_fn("tag", |commands: &mut Array, id: rhai::ImmutableString| {
272        if let Some(last) = commands.last_mut() {
273            let command = std::mem::take(last);
274            let mut request = Map::new();
275            request.insert(TAG_ID_KEY.into(), Dynamic::from(id));
276            request.insert(TAG_COMMAND_KEY.into(), command);
277            *last = Dynamic::from_map(request);
278        }
279    });
280    crate::command::register_command_methods(engine);
281}
282
283/// Registers mixed integer and float arithmetic and comparison operators. rhai
284/// has no built-in operator for an integer and a float together, so a loop index
285/// times a float, or a comparison between the two, would need `.to_float()` on
286/// every term. These overloads let a script write `index * 1.5` and `time < 2`
287/// directly. Same-type arithmetic is untouched, so integer division stays integer.
288fn register_mixed_number_ops(engine: &mut Engine) {
289    engine.register_fn("+", |a: i64, b: f64| a as f64 + b);
290    engine.register_fn("+", |a: f64, b: i64| a + b as f64);
291    engine.register_fn("-", |a: i64, b: f64| a as f64 - b);
292    engine.register_fn("-", |a: f64, b: i64| a - b as f64);
293    engine.register_fn("*", |a: i64, b: f64| a as f64 * b);
294    engine.register_fn("*", |a: f64, b: i64| a * b as f64);
295    engine.register_fn("/", |a: i64, b: f64| a as f64 / b);
296    engine.register_fn("/", |a: f64, b: i64| a / b as f64);
297    engine.register_fn("%", |a: i64, b: f64| a as f64 % b);
298    engine.register_fn("%", |a: f64, b: i64| a % b as f64);
299    engine.register_fn("<", |a: i64, b: f64| (a as f64) < b);
300    engine.register_fn("<", |a: f64, b: i64| a < b as f64);
301    engine.register_fn(">", |a: i64, b: f64| a as f64 > b);
302    engine.register_fn(">", |a: f64, b: i64| a > b as f64);
303    engine.register_fn("<=", |a: i64, b: f64| a as f64 <= b);
304    engine.register_fn("<=", |a: f64, b: i64| a <= b as f64);
305    engine.register_fn(">=", |a: i64, b: f64| a as f64 >= b);
306    engine.register_fn(">=", |a: f64, b: i64| a >= b as f64);
307}
308
309/// Registers `rgb`, `rgba`, and `hsv`, which build the `[r, g, b, a]` color array
310/// commands take, so a script asks for a color by intent instead of writing the
311/// channels out, and gets a hue gradient from `hsv` without hand-rolling the math.
312/// Registers vector math on the plain arrays the API already uses for
313/// positions and directions, so a script computing movement or aiming can write
314/// `direction(pos, target)` or `dot(a, b)` instead of indexing `[0], [1], [2]`
315/// and doing the arithmetic by hand. Every helper reads the first three
316/// components and tolerates integer or float entries.
317fn register_vector_helpers(engine: &mut Engine) {
318    fn axis(value: &Dynamic) -> f64 {
319        value
320            .as_float()
321            .ok()
322            .or_else(|| value.as_int().ok().map(|integer| integer as f64))
323            .unwrap_or(0.0)
324    }
325    fn components(vector: &Array) -> (f64, f64, f64) {
326        (
327            vector.first().map(axis).unwrap_or(0.0),
328            vector.get(1).map(axis).unwrap_or(0.0),
329            vector.get(2).map(axis).unwrap_or(0.0),
330        )
331    }
332    fn vector3(x: f64, y: f64, z: f64) -> Array {
333        vec![Dynamic::from(x), Dynamic::from(y), Dynamic::from(z)]
334    }
335
336    engine.register_fn("vec3", |x: f64, y: f64, z: f64| vector3(x, y, z));
337    engine.register_fn("dot", |a: Array, b: Array| {
338        let (ax, ay, az) = components(&a);
339        let (bx, by, bz) = components(&b);
340        ax * bx + ay * by + az * bz
341    });
342    engine.register_fn("cross", |a: Array, b: Array| {
343        let (ax, ay, az) = components(&a);
344        let (bx, by, bz) = components(&b);
345        vector3(ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx)
346    });
347    engine.register_fn("length", |v: Array| {
348        let (x, y, z) = components(&v);
349        (x * x + y * y + z * z).sqrt()
350    });
351    engine.register_fn("normalize", |v: Array| {
352        let (x, y, z) = components(&v);
353        let len = (x * x + y * y + z * z).sqrt();
354        if len > 1e-9 {
355            vector3(x / len, y / len, z / len)
356        } else {
357            vector3(0.0, 0.0, 0.0)
358        }
359    });
360    engine.register_fn("distance", |a: Array, b: Array| {
361        let (ax, ay, az) = components(&a);
362        let (bx, by, bz) = components(&b);
363        ((ax - bx).powi(2) + (ay - by).powi(2) + (az - bz).powi(2)).sqrt()
364    });
365    engine.register_fn("scaled", |v: Array, s: f64| {
366        let (x, y, z) = components(&v);
367        vector3(x * s, y * s, z * s)
368    });
369    engine.register_fn("vadd", |a: Array, b: Array| {
370        let (ax, ay, az) = components(&a);
371        let (bx, by, bz) = components(&b);
372        vector3(ax + bx, ay + by, az + bz)
373    });
374    engine.register_fn("vsub", |a: Array, b: Array| {
375        let (ax, ay, az) = components(&a);
376        let (bx, by, bz) = components(&b);
377        vector3(ax - bx, ay - by, az - bz)
378    });
379    engine.register_fn("direction", |from: Array, to: Array| {
380        let (ax, ay, az) = components(&from);
381        let (bx, by, bz) = components(&to);
382        let (dx, dy, dz) = (bx - ax, by - ay, bz - az);
383        let len = (dx * dx + dy * dy + dz * dz).sqrt();
384        if len > 1e-9 {
385            vector3(dx / len, dy / len, dz / len)
386        } else {
387            vector3(0.0, 0.0, 0.0)
388        }
389    });
390    engine.register_fn("lerp", |a: f64, b: f64, t: f64| a + (b - a) * t);
391    engine.register_fn("lerp", |a: Array, b: Array, t: f64| {
392        let (ax, ay, az) = components(&a);
393        let (bx, by, bz) = components(&b);
394        vector3(ax + (bx - ax) * t, ay + (by - ay) * t, az + (bz - az) * t)
395    });
396    engine.register_fn("clamp", |x: f64, lo: f64, hi: f64| x.max(lo).min(hi));
397}
398
399fn register_color_helpers(engine: &mut Engine) {
400    engine.register_fn("rgb", |r: f64, g: f64, b: f64| -> Array {
401        vec![
402            Dynamic::from(r),
403            Dynamic::from(g),
404            Dynamic::from(b),
405            Dynamic::from(1.0_f64),
406        ]
407    });
408    engine.register_fn("rgba", |r: f64, g: f64, b: f64, a: f64| -> Array {
409        vec![
410            Dynamic::from(r),
411            Dynamic::from(g),
412            Dynamic::from(b),
413            Dynamic::from(a),
414        ]
415    });
416    engine.register_fn("hsv", |h: f64, s: f64, v: f64| -> Array {
417        let (r, g, b) = hsv_to_rgb(h, s, v);
418        vec![
419            Dynamic::from(r),
420            Dynamic::from(g),
421            Dynamic::from(b),
422            Dynamic::from(1.0_f64),
423        ]
424    });
425}
426
427/// Converts hue, saturation, value (each in `0..=1`, hue wrapping) to red, green,
428/// blue. The standard piecewise conversion, used by the `hsv` color helper.
429fn hsv_to_rgb(hue: f64, saturation: f64, value: f64) -> (f64, f64, f64) {
430    let sector = hue.rem_euclid(1.0) * 6.0;
431    let chroma = value * saturation;
432    let secondary = chroma * (1.0 - (sector % 2.0 - 1.0).abs());
433    let base = value - chroma;
434    let (red, green, blue) = match sector as i64 {
435        0 => (chroma, secondary, 0.0),
436        1 => (secondary, chroma, 0.0),
437        2 => (0.0, chroma, secondary),
438        3 => (0.0, secondary, chroma),
439        4 => (secondary, 0.0, chroma),
440        _ => (chroma, 0.0, secondary),
441    };
442    (red + base, green + base, blue + base)
443}
444
445/// True if the script defines a zero-argument function with this name, so a
446/// lifecycle hook is only called when the script actually has it.
447fn ast_defines_hook(ast: &AST, name: &str) -> bool {
448    ast.iter_functions()
449        .any(|function| function.name == name && function.params.is_empty())
450}
451
452/// Whether the AST defines `name` taking `params` arguments, for the event hooks
453/// the runner calls with data, like `on_collision(event)`.
454fn ast_defines_hook_arg(ast: &AST, name: &str, params: usize) -> bool {
455    ast.iter_functions()
456        .any(|function| function.name == name && function.params.len() == params)
457}
458
459/// Turns a command's reply into a rhai value a script can read out of `replies`.
460/// Entities become the packed handle `entity_ref` accepts, scalars become their
461/// rhai counterparts, vectors and lists become arrays, and a json result becomes
462/// the equivalent rhai map or array.
463fn reply_to_dynamic(reply: &CommandReply) -> Dynamic {
464    match reply {
465        CommandReply::None => Dynamic::UNIT,
466        CommandReply::Entity(entity) => Dynamic::from(entity_to_script(*entity)),
467        CommandReply::Bool(value) => Dynamic::from(*value),
468        CommandReply::Float(value) => Dynamic::from(*value as f64),
469        CommandReply::Int(value) => Dynamic::from(*value),
470        CommandReply::Text(value) => Dynamic::from(value.clone()),
471        CommandReply::Vector(value) => {
472            Dynamic::from_array(array3(Vec3::new(value[0], value[1], value[2])))
473        }
474        CommandReply::Entities(entities) => Dynamic::from_array(
475            entities
476                .iter()
477                .map(|entity| Dynamic::from(entity_to_script(*entity)))
478                .collect(),
479        ),
480        CommandReply::Strings(strings) => Dynamic::from_array(
481            strings
482                .iter()
483                .map(|value| Dynamic::from(value.clone()))
484                .collect(),
485        ),
486        CommandReply::Bytes(bytes) => Dynamic::from_blob(bytes.clone()),
487        CommandReply::Json(value) => rhai::serde::to_dynamic(value).unwrap_or(Dynamic::UNIT),
488        CommandReply::Error(message) => {
489            let mut map = Map::new();
490            map.insert("error".into(), Dynamic::from(message.clone()));
491            Dynamic::from_map(map)
492        }
493    }
494}
495
496/// Packs an entity into the `i64` a script holds. Generational, so a stale id
497/// resolves to nothing rather than the new occupant of a reused slot.
498fn entity_to_script(entity: Entity) -> i64 {
499    (((entity.id as u64) << 32) | entity.generation as u64) as i64
500}
501
502/// Unpacks the `i64` a script passed back into an entity.
503fn script_to_entity(value: i64) -> Entity {
504    let bits = value as u64;
505    Entity {
506        id: (bits >> 32) as u32,
507        generation: bits as u32,
508    }
509}
510
511fn hash_source(source: &str) -> u64 {
512    use std::hash::{Hash, Hasher};
513    let mut hasher = std::collections::hash_map::DefaultHasher::new();
514    source.hash(&mut hasher);
515    hasher.finish()
516}
517
518fn compiled_ast<'a>(
519    runtime: &'a mut ScriptRuntime,
520    source: &str,
521    errors: &mut Vec<String>,
522) -> Option<&'a AST> {
523    let key = hash_source(source);
524    if !runtime.compiled.contains_key(&key) {
525        match runtime.engine.compile(source) {
526            Ok(ast) => {
527                runtime.compiled.insert(key, ast);
528            }
529            Err(error) => {
530                tracing::error!("script compile error: {error}");
531                errors.push(format!("compile error: {error}"));
532                return None;
533            }
534        }
535    }
536    runtime.compiled.get(&key)
537}
538
539/// Drops a despawned entity's scope so the runtime does not leak.
540pub fn script_runtime_forget_entity(runtime: &mut ScriptRuntime, entity: Entity) {
541    runtime.scopes.remove(&entity);
542    runtime.started_entities.remove(&entity);
543}
544
545/// Compiles a script source and returns any syntax error, without running it,
546/// so a tool can validate scripts ahead of time. Compilation checks syntax, not
547/// whether a called command exists, which is resolved when the script runs.
548/// Registers `source` as an importable module named `name`, so other scripts can
549/// `import "name" as alias;` and call its functions. This is how a game grows a
550/// library of its own reusable scripts alongside the bundled `std/*` modules.
551pub fn script_runtime_register_module(
552    runtime: &mut ScriptRuntime,
553    name: &str,
554    source: &str,
555) -> Result<(), String> {
556    let ast = runtime
557        .engine
558        .compile(source)
559        .map_err(|error| error.to_string())?;
560    let module = rhai::Module::eval_ast_as_new(Scope::new(), &ast, &runtime.engine)
561        .map_err(|error| error.to_string())?;
562    runtime.modules.insert(name, module);
563    Ok(())
564}
565
566pub fn script_check(source: &str) -> Result<(), String> {
567    new_engine(&ScriptModules::default())
568        .compile(source)
569        .map(|_| ())
570        .map_err(|error| error.to_string())
571}
572
573/// Sets the rhai operation budget per script call. The default is conservative
574/// to bound an untrusted script; a trusted creative driver, where a script may
575/// draw thousands of shapes a frame, raises it. 0 removes the limit.
576pub fn script_runtime_set_max_operations(runtime: &mut ScriptRuntime, max: u64) {
577    runtime.engine.set_max_operations(max);
578}
579
580/// Sets the largest array a script may build, which also bounds how many
581/// commands one tick may produce. 0 removes the limit.
582pub fn script_runtime_set_max_array_size(runtime: &mut ScriptRuntime, max: usize) {
583    runtime.engine.set_max_array_size(max);
584}
585
586/// Registers asset bytes a script can reach as a blob under `assets.name`, for
587/// passing to a command like spawn_model. The driver supplies them, bundled or
588/// fetched, so a script can spawn a glb without any file access of its own.
589pub fn script_runtime_set_asset(runtime: &mut ScriptRuntime, name: &str, bytes: Vec<u8>) {
590    runtime.assets.insert(name.to_string(), bytes);
591}
592
593/// Clears every compiled script and scope, so a re-entry starts fresh and each
594/// script's `on_start` runs again.
595pub fn script_runtime_reset(runtime: &mut ScriptRuntime) {
596    runtime.compiled.clear();
597    runtime.scopes.clear();
598    runtime.global_scopes.clear();
599    runtime.started_globals.clear();
600    runtime.started_entities.clear();
601    runtime.next_replies.clear();
602    runtime.random_state = RANDOM_SEED;
603}
604
605/// Runs every enabled script once against this tick's frozen events, submits the
606/// commands they produced as one deferred batch, and returns those commands so a
607/// tool can show the traffic. A script that produces a malformed command has it
608/// dropped.
609pub fn run_scripts(world: &mut World, runtime: &mut ScriptRuntime) -> ScriptReport {
610    SCRIPT_RNG.with(|cell| cell.set(runtime.random_state));
611    SCRIPT_LOGS.with(|logs| logs.borrow_mut().clear());
612
613    let frame_events: Vec<Event> = events(world).to_vec();
614
615    for event in &frame_events {
616        if let Event::Despawned { entity } = event {
617            script_runtime_forget_entity(runtime, *entity);
618        }
619    }
620
621    let policy = runtime.policy.clone();
622    let dt = world.resources.window.timing.delta_time as f64;
623    let time = world.resources.window.timing.uptime_milliseconds as f64 / 1000.0;
624
625    // Gamepad sticks and connection, read first: the accessors poll the pad and
626    // need a mutable world, before the immutable input borrows below.
627    #[cfg(feature = "gamepad")]
628    let gamepad = {
629        let left = crate::input::gamepad_left_stick(world);
630        let right = crate::input::gamepad_right_stick(world);
631        let connected = crate::input::gamepad_connected(world);
632        let mut map = Map::new();
633        map.insert(
634            "left_stick".into(),
635            Dynamic::from_array(vec![
636                Dynamic::from(left.x as f64),
637                Dynamic::from(left.y as f64),
638            ]),
639        );
640        map.insert(
641            "right_stick".into(),
642            Dynamic::from_array(vec![
643                Dynamic::from(right.x as f64),
644                Dynamic::from(right.y as f64),
645            ]),
646        );
647        map.insert("connected".into(), Dynamic::from(connected));
648        map
649    };
650    #[cfg(not(feature = "gamepad"))]
651    let gamepad = Map::new();
652
653    let keyboard = &world.resources.input.keyboard;
654    let move_left =
655        keyboard.is_key_pressed(KeyCode::ArrowLeft) || keyboard.is_key_pressed(KeyCode::KeyA);
656    let move_right =
657        keyboard.is_key_pressed(KeyCode::ArrowRight) || keyboard.is_key_pressed(KeyCode::KeyD);
658    let launch = keyboard.is_key_pressed(KeyCode::Space);
659
660    // General input: every held key in `keys`, every key that went down this
661    // frame in `pressed`, keyed by their KeyCode name (e.g. "KeyW", "ArrowLeft",
662    // "Space"). Scripts test membership with `"KeyW" in keys`. The paddle bools
663    // above stay for existing scripts.
664    let mut keys = Map::new();
665    for keycode in keyboard.keystates.keys() {
666        if keyboard.is_key_pressed(*keycode) {
667            keys.insert(format!("{keycode:?}").into(), Dynamic::from(true));
668        }
669    }
670    let mut pressed = Map::new();
671    for keycode in &keyboard.just_pressed_keys {
672        pressed.insert(format!("{keycode:?}").into(), Dynamic::from(true));
673    }
674    let mouse = &world.resources.input.mouse;
675    let mut mouse_map = Map::new();
676    mouse_map.insert("x".into(), Dynamic::from(mouse.position.x as f64));
677    mouse_map.insert("y".into(), Dynamic::from(mouse.position.y as f64));
678    mouse_map.insert(
679        "left".into(),
680        Dynamic::from(mouse.state.contains(MouseState::LEFT_CLICKED)),
681    );
682    mouse_map.insert(
683        "right".into(),
684        Dynamic::from(mouse.state.contains(MouseState::RIGHT_CLICKED)),
685    );
686    mouse_map.insert(
687        "middle".into(),
688        Dynamic::from(mouse.state.contains(MouseState::MIDDLE_CLICKED)),
689    );
690    mouse_map.insert(
691        "left_pressed".into(),
692        Dynamic::from(mouse.state.contains(MouseState::LEFT_JUST_PRESSED)),
693    );
694    mouse_map.insert(
695        "right_pressed".into(),
696        Dynamic::from(mouse.state.contains(MouseState::RIGHT_JUST_PRESSED)),
697    );
698    mouse_map.insert("scroll".into(), Dynamic::from(mouse.wheel_delta.y as f64));
699    mouse_map.insert("dx".into(), Dynamic::from(mouse.position_delta.x as f64));
700    mouse_map.insert("dy".into(), Dynamic::from(mouse.position_delta.y as f64));
701
702    // Active touch points as [{ id, x, y }], and their count, so touch input
703    // (mobile, trackpad) is reachable the same way the mouse map is.
704    let touch = &world.resources.input.touch;
705    let mut touch_list = Array::new();
706    for point in touch.touches.values() {
707        let mut entry = Map::new();
708        entry.insert("id".into(), Dynamic::from(point.id as i64));
709        entry.insert("x".into(), Dynamic::from(point.position.x as f64));
710        entry.insert("y".into(), Dynamic::from(point.position.y as f64));
711        touch_list.push(Dynamic::from_map(entry));
712    }
713    let touch_count = touch_list.len() as i64;
714
715    // Where the cursor ray meets the ground plane (y = 0), as [x, y, z], or an
716    // empty array when it misses or picking is unavailable. For world placement.
717    #[cfg(feature = "picking")]
718    let ground_hit = nightshade::prelude::get_ground_position_from_screen(
719        world,
720        world.resources.input.mouse.position,
721        0.0,
722    );
723    #[cfg(not(feature = "picking"))]
724    let ground_hit: Option<Vec3> = None;
725    let ground = match ground_hit {
726        Some(point) => array3(point),
727        None => Array::new(),
728    };
729    let pointer_over_ui = world
730        .resources
731        .retained_ui
732        .interaction
733        .hovered_entity
734        .is_some();
735
736    let mut named = Map::new();
737    let mut positions = Map::new();
738    let mut velocities = Map::new();
739    for (name, entity) in &world.resources.entities.names {
740        named.insert(
741            name.as_str().into(),
742            Dynamic::from(entity_to_script(*entity)),
743        );
744        let (position, velocity) = entity_pos_vel(world, *entity);
745        positions.insert(name.as_str().into(), Dynamic::from(position));
746        velocities.insert(name.as_str().into(), Dynamic::from(velocity));
747    }
748
749    // Entities grouped by tag, each as { entity, pos, vel }, so a script can find
750    // and react to a whole class of entities, not just named ones.
751    let mut tag_lists: HashMap<String, Array> = HashMap::new();
752    for (entity, tags) in &world.resources.entities.tags {
753        let (position, velocity) = entity_pos_vel(world, *entity);
754        let mut record = Map::new();
755        record.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
756        record.insert("pos".into(), Dynamic::from(position));
757        record.insert("vel".into(), Dynamic::from(velocity));
758        let record = Dynamic::from_map(record);
759        for tag in tags {
760            tag_lists
761                .entry(tag.clone())
762                .or_default()
763                .push(record.clone());
764        }
765    }
766    let mut tagged = Map::new();
767    for (tag, list) in tag_lists {
768        tagged.insert(tag.as_str().into(), Dynamic::from(list));
769    }
770
771    // Read-side interactive state a script branches on: `widgets` is each tagged
772    // widget's current value keyed by its tag, `picked` is the entity clicked this
773    // frame (or unit), `camera` is the active camera's pose.
774    let mut widgets = Map::new();
775    for (entity, tags) in &world.resources.entities.tags {
776        if let Some(value) = widget_value(world, *entity) {
777            for tag in tags {
778                widgets.insert(tag.as_str().into(), value.clone());
779            }
780        }
781    }
782    #[cfg(feature = "picking")]
783    let picked = match crate::picking::clicked_entity(world) {
784        Some(entity) => Dynamic::from(entity_to_script(entity)),
785        None => Dynamic::UNIT,
786    };
787    #[cfg(not(feature = "picking"))]
788    let picked = Dynamic::UNIT;
789    let mut camera = Map::new();
790    camera.insert(
791        "pos".into(),
792        Dynamic::from_array(array3(crate::camera::camera_position(world))),
793    );
794    camera.insert(
795        "forward".into(),
796        Dynamic::from_array(array3(crate::camera::camera_forward(world))),
797    );
798
799    let mut produced: Vec<Command> = Vec::new();
800    let mut result_tags: Vec<(usize, String)> = Vec::new();
801    let mut errors: Vec<String> = Vec::new();
802    let replies = std::mem::take(&mut runtime.next_replies);
803
804    let global_scripts: Vec<(String, String)> = world
805        .resources
806        .global_scripts
807        .entries
808        .iter()
809        .filter(|script| script.enabled && !script.source.trim().is_empty())
810        .map(|script| (script.name.clone(), script.source.clone()))
811        .collect();
812
813    for (name, source) in &global_scripts {
814        let Some(ast) = compiled_ast(runtime, source, &mut errors).cloned() else {
815            continue;
816        };
817        let run_start =
818            !runtime.started_globals.contains(name) && ast_defines_hook(&ast, "on_start");
819        runtime.started_globals.insert(name.clone());
820        let scope = runtime.global_scopes.entry(name.clone()).or_default();
821        if !scope.contains("state") {
822            scope.set_value("state", Map::new());
823        }
824        if !scope.contains("assets") {
825            let mut assets = Map::new();
826            for (name, bytes) in &runtime.assets {
827                assets.insert(name.as_str().into(), Dynamic::from_blob(bytes.clone()));
828            }
829            scope.set_value("assets", assets);
830        }
831        scope.set_value("dt", dt);
832        scope.set_value("time", time);
833        scope.set_value("tau", std::f64::consts::TAU);
834        scope.set_value("pi", std::f64::consts::PI);
835        scope.set_value("replies", replies.clone());
836        scope.set_value("move_left", move_left);
837        scope.set_value("move_right", move_right);
838        scope.set_value("launch", launch);
839        scope.set_value("keys", keys.clone());
840        scope.set_value("pressed", pressed.clone());
841        scope.set_value("mouse", mouse_map.clone());
842        scope.set_value("gamepad", gamepad.clone());
843        scope.set_value("touches", touch_list.clone());
844        scope.set_value("touch_count", touch_count);
845        scope.set_value("named", named.clone());
846        scope.set_value("positions", positions.clone());
847        scope.set_value("velocities", velocities.clone());
848        scope.set_value("tagged", tagged.clone());
849        scope.set_value("widgets", widgets.clone());
850        scope.set_value("picked", picked.clone());
851        scope.set_value("camera", camera.clone());
852        scope.set_value("ground", ground.clone());
853        scope.set_value("pointer_over_ui", pointer_over_ui);
854        scope.set_value("events", events_array(&frame_events));
855        scope.set_value("commands", Array::new());
856
857        if run_start {
858            if let Err(error) = runtime.engine.call_fn::<()>(scope, &ast, "on_start", ()) {
859                tracing::warn!("global script '{name}' on_start error: {error}");
860                errors.push(format!("on_start error: {error}"));
861            } else {
862                collect_commands(scope, &mut produced, &mut result_tags, &mut errors, &policy);
863            }
864            scope.set_value("commands", Array::new());
865        }
866
867        if ast_defines_hook(&ast, "on_tick") {
868            if let Err(error) = runtime.engine.call_fn::<()>(scope, &ast, "on_tick", ()) {
869                tracing::warn!("global script '{name}' on_tick error: {error}");
870                errors.push(format!("on_tick error: {error}"));
871                continue;
872            }
873            collect_commands(scope, &mut produced, &mut result_tags, &mut errors, &policy);
874        }
875
876        if ast_defines_hook_arg(&ast, "on_collision", 1) {
877            for event in &frame_events {
878                if !matches!(event, Event::Collision { .. }) {
879                    continue;
880                }
881                scope.set_value("commands", Array::new());
882                if let Err(error) = runtime.engine.call_fn::<()>(
883                    scope,
884                    &ast,
885                    "on_collision",
886                    (event_to_map(event),),
887                ) {
888                    errors.push(format!("on_collision error: {error}"));
889                } else {
890                    collect_commands(scope, &mut produced, &mut result_tags, &mut errors, &policy);
891                }
892            }
893        }
894    }
895
896    let mut scripted: Vec<(Entity, String)> = Vec::new();
897    for entity in world.ecs.worlds[CORE]
898        .query_entities(SCRIPT)
899        .collect::<Vec<_>>()
900    {
901        if let Some(script) = world.get::<nightshade::ecs::script::components::Script>(entity)
902            && script.enabled
903            && let ScriptSource::Embedded { source } = &script.source
904        {
905            scripted.push((entity, source.clone()));
906        }
907    }
908
909    for (entity, source) in &scripted {
910        let position = world
911            .get::<nightshade::ecs::transform::components::LocalTransform>(*entity)
912            .map(|transform| transform.translation)
913            .unwrap_or_else(Vec3::zeros);
914        let velocity = nightshade::ecs::physics::resources::physics_world_linear_velocity(
915            &world.resources.physics,
916            *entity,
917        )
918        .unwrap_or_else(Vec3::zeros);
919        let entity_events = relevant_events(&frame_events, *entity);
920
921        let Some(ast) = compiled_ast(runtime, source, &mut errors).cloned() else {
922            continue;
923        };
924        let run_start =
925            !runtime.started_entities.contains(entity) && ast_defines_hook(&ast, "on_start");
926        runtime.started_entities.insert(*entity);
927        let scope = runtime.scopes.entry(*entity).or_default();
928        if !scope.contains("state") {
929            scope.set_value("state", Map::new());
930        }
931        if !scope.contains("assets") {
932            let mut assets = Map::new();
933            for (name, bytes) in &runtime.assets {
934                assets.insert(name.as_str().into(), Dynamic::from_blob(bytes.clone()));
935            }
936            scope.set_value("assets", assets);
937        }
938        scope.set_value("self", entity_to_script(*entity));
939        scope.set_value("dt", dt);
940        scope.set_value("time", time);
941        scope.set_value("tau", std::f64::consts::TAU);
942        scope.set_value("pi", std::f64::consts::PI);
943        scope.set_value("replies", replies.clone());
944        scope.set_value("move_left", move_left);
945        scope.set_value("move_right", move_right);
946        scope.set_value("launch", launch);
947        scope.set_value("keys", keys.clone());
948        scope.set_value("pressed", pressed.clone());
949        scope.set_value("mouse", mouse_map.clone());
950        scope.set_value("gamepad", gamepad.clone());
951        scope.set_value("touches", touch_list.clone());
952        scope.set_value("touch_count", touch_count);
953        scope.set_value("pos", array3(position));
954        scope.set_value("vel", array3(velocity));
955        scope.set_value("named", named.clone());
956        scope.set_value("positions", positions.clone());
957        scope.set_value("velocities", velocities.clone());
958        scope.set_value("tagged", tagged.clone());
959        scope.set_value("widgets", widgets.clone());
960        scope.set_value("picked", picked.clone());
961        scope.set_value("camera", camera.clone());
962        scope.set_value("ground", ground.clone());
963        scope.set_value("pointer_over_ui", pointer_over_ui);
964        scope.set_value("events", events_array(&entity_events));
965        scope.set_value("commands", Array::new());
966
967        if run_start {
968            if let Err(error) = runtime.engine.call_fn::<()>(scope, &ast, "on_start", ()) {
969                tracing::warn!("script on_start error: {error}");
970                errors.push(format!("on_start error: {error}"));
971            } else {
972                collect_commands(scope, &mut produced, &mut result_tags, &mut errors, &policy);
973            }
974            scope.set_value("commands", Array::new());
975        }
976
977        if ast_defines_hook(&ast, "on_tick") {
978            if let Err(error) = runtime.engine.call_fn::<()>(scope, &ast, "on_tick", ()) {
979                tracing::warn!("script on_tick error: {error}");
980                errors.push(format!("on_tick error: {error}"));
981                continue;
982            }
983            collect_commands(scope, &mut produced, &mut result_tags, &mut errors, &policy);
984        }
985
986        if ast_defines_hook_arg(&ast, "on_collision", 1) {
987            for event in &entity_events {
988                if !matches!(event, Event::Collision { .. }) {
989                    continue;
990                }
991                scope.set_value("commands", Array::new());
992                if let Err(error) = runtime.engine.call_fn::<()>(
993                    scope,
994                    &ast,
995                    "on_collision",
996                    (event_to_map(event),),
997                ) {
998                    errors.push(format!("on_collision error: {error}"));
999                } else {
1000                    collect_commands(scope, &mut produced, &mut result_tags, &mut errors, &policy);
1001                }
1002            }
1003        }
1004    }
1005
1006    let replies_out = submit_commands(world, &produced);
1007    let mut next_replies = Map::new();
1008    for (index, id) in &result_tags {
1009        if let Some(reply) = replies_out.get(*index) {
1010            next_replies.insert(id.as_str().into(), reply_to_dynamic(reply));
1011        }
1012    }
1013    runtime.next_replies = next_replies;
1014    runtime.random_state = SCRIPT_RNG.with(|cell| cell.get());
1015    let logs = SCRIPT_LOGS.with(|logs| std::mem::take(&mut *logs.borrow_mut()));
1016    ScriptReport {
1017        commands: produced,
1018        errors,
1019        logs,
1020    }
1021}
1022
1023/// Reads the script's `commands` array and deserializes each entry into a typed
1024/// [`Command`] through a small json value, which coerces rhai's f64 numbers into
1025/// the command's f32 fields. A command the write policy forbids is dropped with a
1026/// warning rather than applied.
1027fn collect_commands(
1028    scope: &Scope<'static>,
1029    produced: &mut Vec<Command>,
1030    tags: &mut Vec<(usize, String)>,
1031    errors: &mut Vec<String>,
1032    policy: &CommandPolicy,
1033) {
1034    let Some(commands) = scope
1035        .get("commands")
1036        .and_then(|value| value.read_lock::<Array>())
1037    else {
1038        return;
1039    };
1040    for entry in commands.iter() {
1041        let is_tagged = entry
1042            .read_lock::<Map>()
1043            .is_some_and(|map| map.contains_key(TAG_COMMAND_KEY));
1044        let parsed = if is_tagged {
1045            let request = entry.read_lock::<Map>().unwrap();
1046            let id = request
1047                .get(TAG_ID_KEY)
1048                .and_then(|value| value.clone().into_string().ok());
1049            match request.get(TAG_COMMAND_KEY) {
1050                Some(command) => {
1051                    crate::dynamic_de::from_dynamic::<Command>(command).map(|c| (c, id))
1052                }
1053                None => continue,
1054            }
1055        } else {
1056            crate::dynamic_de::from_dynamic::<Command>(entry).map(|c| (c, None))
1057        };
1058        match parsed {
1059            Ok((command, id)) if policy.allows(command.name()) => {
1060                if let Some(id) = id {
1061                    tags.push((produced.len(), id));
1062                }
1063                produced.push(command);
1064            }
1065            Ok((command, _)) => {
1066                tracing::warn!(
1067                    "script command '{}' blocked by write policy",
1068                    command.name()
1069                );
1070                errors.push(format!(
1071                    "command '{}' blocked by write policy",
1072                    command.name()
1073                ));
1074            }
1075            Err(error) => {
1076                tracing::warn!("dropped malformed script command: {error}");
1077                errors.push(format!("dropped command: {error}"));
1078            }
1079        }
1080    }
1081}
1082
1083/// An entity's position and velocity as rhai `[x, y, z]` arrays, for exposing
1084/// other entities to a script. Missing transform or body reads as zero.
1085fn entity_pos_vel(world: &World, entity: Entity) -> (Array, Array) {
1086    let position = world
1087        .get::<nightshade::ecs::transform::components::LocalTransform>(entity)
1088        .map(|transform| transform.translation)
1089        .unwrap_or_else(Vec3::zeros);
1090    let velocity = nightshade::ecs::physics::resources::physics_world_linear_velocity(
1091        &world.resources.physics,
1092        entity,
1093    )
1094    .unwrap_or_else(Vec3::zeros);
1095    (array3(position), array3(velocity))
1096}
1097
1098fn array3(value: Vec3) -> Array {
1099    vec![
1100        Dynamic::from(value.x as f64),
1101        Dynamic::from(value.y as f64),
1102        Dynamic::from(value.z as f64),
1103    ]
1104}
1105
1106fn array4(value: nightshade::prelude::Vec4) -> Array {
1107    vec![
1108        Dynamic::from(value.x as f64),
1109        Dynamic::from(value.y as f64),
1110        Dynamic::from(value.z as f64),
1111        Dynamic::from(value.w as f64),
1112    ]
1113}
1114
1115/// The current value of whatever retained-ui widget `entity` is, as a rhai
1116/// scalar (slider/drag as a number, toggle/checkbox as a bool, dropdown/tab as
1117/// an int, color as `[r, g, b, a]`, text input as a string), or `None` when the
1118/// entity is not a widget. Lets a script read live ui state from the `widgets`
1119/// map instead of a deferred query command that never reports back.
1120fn widget_value(world: &World, entity: Entity) -> Option<Dynamic> {
1121    use nightshade::prelude::{
1122        ui_checkbox_value, ui_collapsing_header_open, ui_color_picker_color, ui_drag_value,
1123        ui_dropdown_selected, ui_slider_value, ui_tab_bar_selected, ui_text_input_text,
1124        ui_toggle_value,
1125    };
1126    if let Some(value) = ui_slider_value(world, entity) {
1127        return Some(Dynamic::from(value as f64));
1128    }
1129    if let Some(value) = ui_drag_value(world, entity) {
1130        return Some(Dynamic::from(value as f64));
1131    }
1132    if let Some(value) = ui_toggle_value(world, entity) {
1133        return Some(Dynamic::from(value));
1134    }
1135    if let Some(value) = ui_checkbox_value(world, entity) {
1136        return Some(Dynamic::from(value));
1137    }
1138    if let Some(value) = ui_dropdown_selected(world, entity) {
1139        return Some(Dynamic::from(value as i64));
1140    }
1141    if let Some(value) = ui_tab_bar_selected(world, entity) {
1142        return Some(Dynamic::from(value as i64));
1143    }
1144    if let Some(value) = ui_collapsing_header_open(world, entity) {
1145        return Some(Dynamic::from(value));
1146    }
1147    if let Some(value) = ui_color_picker_color(world, entity) {
1148        return Some(Dynamic::from_array(array4(value)));
1149    }
1150    if let Some(value) = ui_text_input_text(world, entity) {
1151        return Some(Dynamic::from(value.to_string()));
1152    }
1153    None
1154}
1155
1156/// The events that name `entity`: a collision it took part in, or an entity
1157/// scoped fact about it.
1158fn relevant_events(frame_events: &[Event], entity: Entity) -> Vec<Event> {
1159    frame_events
1160        .iter()
1161        .filter(|event| match event {
1162            Event::Collision { a, b, .. } => *a == entity || *b == entity,
1163            Event::Despawned { entity: target }
1164            | Event::AnimationFinished { entity: target }
1165            | Event::AnimationEvent { entity: target, .. }
1166            | Event::NavigationArrived { entity: target } => *target == entity,
1167        })
1168        .cloned()
1169        .collect()
1170}
1171
1172fn events_array(frame_events: &[Event]) -> Array {
1173    frame_events.iter().map(event_to_map).collect()
1174}
1175
1176fn event_to_map(event: &Event) -> Dynamic {
1177    let mut map = Map::new();
1178    match event {
1179        Event::Collision {
1180            a,
1181            b,
1182            sensor,
1183            started,
1184        } => {
1185            map.insert("kind".into(), Dynamic::from("collision".to_string()));
1186            map.insert("a".into(), Dynamic::from(entity_to_script(*a)));
1187            map.insert("b".into(), Dynamic::from(entity_to_script(*b)));
1188            map.insert("sensor".into(), Dynamic::from(*sensor));
1189            map.insert("started".into(), Dynamic::from(*started));
1190        }
1191        Event::Despawned { entity } => {
1192            map.insert("kind".into(), Dynamic::from("despawned".to_string()));
1193            map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
1194        }
1195        Event::AnimationFinished { entity } => {
1196            map.insert(
1197                "kind".into(),
1198                Dynamic::from("animation_finished".to_string()),
1199            );
1200            map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
1201        }
1202        Event::AnimationEvent { entity, name } => {
1203            map.insert("kind".into(), Dynamic::from("animation_event".to_string()));
1204            map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
1205            map.insert("name".into(), Dynamic::from(name.clone()));
1206        }
1207        Event::NavigationArrived { entity } => {
1208            map.insert(
1209                "kind".into(),
1210                Dynamic::from("navigation_arrived".to_string()),
1211            );
1212            map.insert("entity".into(), Dynamic::from(entity_to_script(*entity)));
1213        }
1214    }
1215    Dynamic::from_map(map)
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220    use super::{ScriptModules, new_engine};
1221
1222    #[test]
1223    fn mixed_integer_and_float_arithmetic_works() {
1224        let engine = new_engine(&ScriptModules::default());
1225        assert_eq!(engine.eval::<f64>("3 * 1.5").unwrap(), 4.5);
1226        assert_eq!(engine.eval::<f64>("2.0 + 3").unwrap(), 5.0);
1227        assert_eq!(engine.eval::<f64>("7 / 2.0").unwrap(), 3.5);
1228        assert!(engine.eval::<bool>("2 < 2.5").unwrap());
1229        assert!(engine.eval::<bool>("3.0 >= 3").unwrap());
1230    }
1231
1232    #[test]
1233    fn same_type_integer_division_stays_integer() {
1234        let engine = new_engine(&ScriptModules::default());
1235        assert_eq!(engine.eval::<i64>("7 / 2").unwrap(), 3);
1236    }
1237
1238    #[test]
1239    fn closures_capture_and_mutate_outer_state() {
1240        let engine = new_engine(&ScriptModules::default());
1241        let result = engine
1242            .eval::<i64>(
1243                r#"
1244                let state = #{ n: 0 };
1245                let bump = || { state.n += 1; };
1246                bump.call();
1247                bump.call();
1248                state.n
1249            "#,
1250            )
1251            .unwrap();
1252        assert_eq!(result, 2);
1253    }
1254
1255    #[test]
1256    fn color_helpers_build_four_channel_arrays() {
1257        let engine = new_engine(&ScriptModules::default());
1258        assert_eq!(
1259            engine
1260                .eval::<rhai::Array>("rgb(0.5, 0.25, 0.1)")
1261                .unwrap()
1262                .len(),
1263            4
1264        );
1265        assert_eq!(
1266            engine
1267                .eval::<rhai::Array>("rgba(0.1, 0.2, 0.3, 0.4)")
1268                .unwrap()
1269                .len(),
1270            4
1271        );
1272        assert_eq!(
1273            engine
1274                .eval::<rhai::Array>("hsv(0.0, 1.0, 1.0)")
1275                .unwrap()
1276                .len(),
1277            4
1278        );
1279    }
1280}