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