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