Skip to main content

libdqg/
scripting.rs

1use std::cell::{Cell, RefCell};
2use std::collections::{HashMap, HashSet};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::rc::Rc;
6use std::sync::Arc;
7
8use glam::{Quat, Vec3};
9use rhai::{CustomType, Dynamic, Engine, FnPtr, Scope, TypeBuilder, AST};
10use serde::{Deserialize, Serialize};
11
12use crate::ecs::{ComponentStore, Entity};
13use crate::input::{InputState, MouseState};
14use crate::renderer::{Model, Renderer, Sprite, Texture};
15use crate::types::KeyCode;
16use crate::world::{Name, Renderable, Transform, World};
17
18/// One script file attached to an entity. `path` is project-root-relative, the same convention
19/// [`crate::world::Renderable`]'s serialized form uses for its own asset paths.
20#[derive(Clone, Serialize, Deserialize)]
21pub struct ScriptAttachment {
22    pub path: PathBuf,
23    #[serde(default = "default_enabled")]
24    pub enabled: bool,
25}
26
27fn default_enabled() -> bool {
28    true
29}
30
31/// The scripts attached to one entity, executed front-to-back. Doubles as both the ECS
32/// component and the serialized form — unlike [`crate::world::Renderable`], a script has no
33/// live GPU-backed counterpart that needs a separate runtime representation.
34#[derive(Clone, Default, Serialize, Deserialize)]
35pub struct ScriptList(pub Vec<ScriptAttachment>);
36
37/// Identifies which entity a [`WorldCommand`] targets: either a real, already-allocated
38/// [`Entity`], or one queued for spawn earlier in the same [`ScriptRuntime::drain_commands`]
39/// batch and not yet resolved to a real handle (see [`ScriptWorld::spawn`]/
40/// [`WorldCommand::Spawn`]).
41#[derive(Clone, Copy)]
42enum ScriptEntityId {
43    Real(Entity),
44    Pending(u64),
45}
46
47/// An intent queued by an [`EntityHandle`]/[`ScriptWorld`] method, applied by
48/// [`ScriptRuntime::drain_commands`] immediately after the `on_start`/`on_update` call that
49/// queued it returns. Nothing reachable from inside a Rhai call can hold a live `&mut World` —
50/// `rhai`'s custom types must be `Clone + 'static`, which a borrow isn't — so this queue (a
51/// cheap `Rc<RefCell<...>>` handle) is how scripts affect `World` anyway: they record an intent
52/// here instead of mutating directly, and the host (which *does* have `&mut World` right around
53/// each script call) applies it afterward. Applying strictly in emission order means later
54/// commands targeting an entity a prior command in the same batch already despawned simply
55/// resolve to nothing and no-op, rather than needing special-case handling.
56enum WorldCommand {
57    SetTransform(ScriptEntityId, Transform),
58    Rename(ScriptEntityId, String),
59    Despawn(ScriptEntityId),
60    Spawn { pending_id: u64, name: String, transform: Transform },
61    AttachScript(ScriptEntityId, PathBuf),
62    SetScriptEnabled(ScriptEntityId, usize, bool),
63    /// Loads a texture at `path` (project-relative) and attaches it as a [`Renderable::Sprite`],
64    /// replacing whatever renderable (if any) the entity already had — "attach" and "swap" are
65    /// the same command, just depending on whether there was one before. Unlike every other
66    /// [`WorldCommand`], applying this needs a live [`Renderer`], so it's only handled when
67    /// [`ScriptRuntime::drain_commands`] is given one (see that method's doc comment).
68    SetSprite(ScriptEntityId, PathBuf),
69    /// The [`Renderable::Model`] counterpart to [`WorldCommand::SetSprite`] — same path
70    /// resolution, same renderer requirement.
71    SetModel(ScriptEntityId, PathBuf),
72    /// Removes whatever renderable the entity has, if any. Unlike `SetSprite`/`SetModel`, this
73    /// needs no [`Renderer`] — there's nothing to load.
74    ClearRenderable(ScriptEntityId),
75    /// Marks/unmarks the entity as persistent across a scene change — see [`World::set_persistent`].
76    /// Applied immediately, unlike `ChangeScene`, since it's just a component flag with nothing
77    /// else to load.
78    SetPersistent(ScriptEntityId, bool),
79    /// Requests a scene change: tear down every non-persistent entity, then load and spawn every
80    /// `EntityRecord` in the `.ron` file at this project-relative path (resolved against
81    /// [`ScriptRuntime::project_root`]) — see [`ScriptRuntime::drain_commands`]'s handling for the
82    /// full sequence. If more than one `ChangeScene` lands in the same `drain_commands` batch, the
83    /// last one applied wins (each fully tears down and reloads over what the previous one just
84    /// loaded) — wasteful but not unsound, and not expected outside pathological scripts.
85    ChangeScene(PathBuf),
86}
87
88/// A read-only copy of every entity's name and [`Transform`], captured once per frame by
89/// [`ScriptRuntime::begin_frame`] before any script runs that frame. `world.find(name)` reads
90/// only this, never the live `World` — for the same reason writes go through [`WorldCommand`]
91/// instead of a live reference. This means a cross-entity read reflects that entity's state as
92/// of the *start* of the frame, even if another script already moved it earlier the same frame —
93/// deterministic and simple to reason about, at the cost of at-most-one-frame staleness for
94/// reads of entities other than self. Self (`entity`) isn't affected by this: it's resynced from
95/// the live `World` before every call, same as always (see [`ScriptRuntime::update_entity`]).
96#[derive(Default)]
97struct FrameSnapshot {
98    transforms: ComponentStore<Transform>,
99    names: ComponentStore<Name>,
100    /// First entity (by spawn order) wins if two entities share a name.
101    by_name: HashMap<String, Entity>,
102}
103
104impl FrameSnapshot {
105    fn capture(world: &World) -> Self {
106        let mut by_name = HashMap::new();
107        for (entity, name) in world.names.iter() {
108            by_name.entry(name.0.clone()).or_insert(entity);
109        }
110        Self { transforms: world.transforms.clone(), names: world.names.clone(), by_name }
111    }
112}
113
114/// A handle to one entity, seen from inside Rhai — either a script's own `entity`, or another
115/// entity reached via `world.find(name)`. Both are this same type with the same methods, so
116/// there's exactly one entity-manipulation surface rather than two parallel ones.
117///
118/// Every mutating method (`translate`/`rotate`/`scale`/the `x`/`y`/`z` setters/`set_name`/
119/// `despawn`/`attach_script`/`set_script_enabled`) does two things: updates this handle's own
120/// cached fields (so a script reading `entity.x` right after setting it still sees its own
121/// write, matching how it felt before cross-entity access existed) and pushes a matching
122/// [`WorldCommand`] onto `commands` — see that type's doc comment for why nothing here can just
123/// mutate `World` directly.
124///
125/// Registered on the [`Engine`] via `#[derive(CustomType)]` (`ScriptRuntime::build_engine`'s
126/// `engine.build_type::<EntityHandle>()`) instead of a hand-written builder chain:
127/// - `#[rhai_type(skip)]` opts every field out of the derive's automatic get/set-property
128///   registration — `x`/`y`/`z` need custom setters now (to also queue a command), so unlike the
129///   original single-entity-only version of this type, nothing here can just be a plain
130///   auto-exposed field anymore.
131/// - `register_extra` (wired up via `#[rhai_type(extra = Self::register_extra)]` below) is where
132///   all of that — properties and methods alike — gets registered, in one place.
133#[derive(Clone, CustomType)]
134#[rhai_type(name = "Entity", extra = Self::register_extra)]
135struct EntityHandle {
136    #[rhai_type(skip)]
137    x: f64,
138    #[rhai_type(skip)]
139    y: f64,
140    #[rhai_type(skip)]
141    z: f64,
142    /// Kept as a [`Quat`], synced in/out of [`Transform::rotation`] as a plain copy — never
143    /// decomposed to/from Euler angles on every frame. An Euler round-trip through
144    /// `to_euler`/`from_euler` each frame is lossy (the middle axis of an Euler triple is
145    /// extracted via `asin`, whose range is capped at ±90°), which made continuous single-axis
146    /// rotation visibly stall once it crossed 90° — a gimbal-lock artifact from re-deriving the
147    /// angle every frame instead of just accumulating it. `rotate()` composes an incremental
148    /// delta quaternion instead, mirroring [`crate::transform::Transformable`]'s own
149    /// post-multiply/local-space rotation convention.
150    #[rhai_type(skip)]
151    rotation: Quat,
152    #[rhai_type(skip)]
153    scale: Vec3,
154    #[rhai_type(skip)]
155    name: String,
156    #[rhai_type(skip)]
157    id: ScriptEntityId,
158    #[rhai_type(skip)]
159    commands: Rc<RefCell<Vec<WorldCommand>>>,
160}
161
162impl EntityHandle {
163    /// Everything the field-level `#[rhai_type]` attributes on [`EntityHandle`] can't express as
164    /// a plain property: the `x`/`y`/`z` get/set pair (custom, not auto, since the setters must
165    /// also queue a [`WorldCommand::SetTransform`]) and the `translate`/`rotate`/`look_at`/
166    /// `scale`/`name`/`set_name`/`despawn`/`set_persistent`/`attach_script`/`set_script_enabled`/
167    /// `set_sprite`/`set_model`/`detach_renderable` methods.
168    fn register_extra(builder: &mut TypeBuilder<Self>) {
169        builder
170            .with_get_set("x", |e: &mut Self| e.x, |e: &mut Self, v: f64| {
171                e.x = v;
172                e.queue_transform_update();
173            })
174            .with_get_set("y", |e: &mut Self| e.y, |e: &mut Self, v: f64| {
175                e.y = v;
176                e.queue_transform_update();
177            })
178            .with_get_set("z", |e: &mut Self| e.z, |e: &mut Self, v: f64| {
179                e.z = v;
180                e.queue_transform_update();
181            })
182            .with_fn("translate", |e: &mut Self, x: f64, y: f64, z: f64| {
183                e.x += x;
184                e.y += y;
185                e.z += z;
186                e.queue_transform_update();
187            })
188            .with_fn("rotate", |e: &mut Self, x: f64, y: f64, z: f64| {
189                let delta = Quat::from_euler(glam::EulerRot::XYZ, x as f32, y as f32, z as f32);
190                e.rotation = (e.rotation * delta).normalize();
191                e.queue_transform_update();
192            })
193            .with_fn("look_at", |e: &mut Self, x: f64, y: f64, z: f64| {
194                let position = Vec3::new(e.x as f32, e.y as f32, e.z as f32);
195                let direction = Vec3::new(x as f32, y as f32, z as f32) - position;
196                // A zero-length direction (looking at your own position) has no meaningful
197                // rotation to face — leave the current rotation alone rather than feeding
198                // `from_rotation_arc` a NaN from normalizing a zero vector.
199                if direction.length_squared() > 1e-12 {
200                    // Sets rotation absolutely (unlike `rotate`, which composes a delta) so the
201                    // entity faces `(x, y, z)` down its local -Z axis — the same "forward" every
202                    // camera entity's `CameraComponent` assumes (see `world::CameraComponent`),
203                    // so this doubles as "point this camera at" for a camera entity.
204                    e.rotation = Quat::from_rotation_arc(Vec3::NEG_Z, direction.normalize());
205                    e.queue_transform_update();
206                }
207            })
208            .with_fn("scale", |e: &mut Self, x: f64, y: f64, z: f64| {
209                e.scale *= Vec3::new(x as f32, y as f32, z as f32);
210                e.queue_transform_update();
211            })
212            .with_fn("name", |e: &mut Self| e.name.clone())
213            .with_fn("set_name", |e: &mut Self, name: &str| {
214                e.name = name.to_string();
215                e.commands.borrow_mut().push(WorldCommand::Rename(e.id, e.name.clone()));
216            })
217            .with_fn("despawn", |e: &mut Self| {
218                e.commands.borrow_mut().push(WorldCommand::Despawn(e.id));
219            })
220            .with_fn("set_persistent", |e: &mut Self, persistent: bool| {
221                e.commands.borrow_mut().push(WorldCommand::SetPersistent(e.id, persistent));
222            })
223            .with_fn("attach_script", |e: &mut Self, path: &str| {
224                e.commands.borrow_mut().push(WorldCommand::AttachScript(e.id, PathBuf::from(path)));
225            })
226            .with_fn("set_script_enabled", |e: &mut Self, index: i64, enabled: bool| {
227                e.commands.borrow_mut().push(WorldCommand::SetScriptEnabled(e.id, index.max(0) as usize, enabled));
228            })
229            .with_fn("set_sprite", |e: &mut Self, path: &str| {
230                e.commands.borrow_mut().push(WorldCommand::SetSprite(e.id, PathBuf::from(path)));
231            })
232            .with_fn("set_model", |e: &mut Self, path: &str| {
233                e.commands.borrow_mut().push(WorldCommand::SetModel(e.id, PathBuf::from(path)));
234            })
235            .with_fn("detach_renderable", |e: &mut Self| {
236                e.commands.borrow_mut().push(WorldCommand::ClearRenderable(e.id));
237            });
238    }
239
240    /// Builds a handle for `id`, seeded from `transform`/`name` — used both for a script's own
241    /// `entity` (seeded from the live `World`) and for a handle `world.find(...)` hands back
242    /// (seeded from the frozen [`FrameSnapshot`]).
243    fn new(id: ScriptEntityId, transform: &Transform, name: String, commands: Rc<RefCell<Vec<WorldCommand>>>) -> Self {
244        Self {
245            x: transform.position.x as f64,
246            y: transform.position.y as f64,
247            z: transform.position.z as f64,
248            rotation: transform.rotation,
249            scale: transform.scale,
250            name,
251            id,
252            commands,
253        }
254    }
255
256    /// Overwrites position/rotation/scale/name from the live `World` — the one place
257    /// [`ScriptRuntime::update_entity`] needs to sync a script's own entity in before calling
258    /// `on_update`, rather than four separate per-field copies. Only ever used for self: a
259    /// handle from `world.find(...)` is seeded once from the frame's frozen snapshot and never
260    /// resynced (see the [`FrameSnapshot`] doc comment for why that's deliberate).
261    fn sync_from_world(&mut self, transform: &Transform, name: &str) {
262        self.x = transform.position.x as f64;
263        self.y = transform.position.y as f64;
264        self.z = transform.position.z as f64;
265        self.rotation = transform.rotation;
266        self.scale = transform.scale;
267        self.name = name.to_string();
268    }
269
270    fn queue_transform_update(&self) {
271        let transform = Transform {
272            position: Vec3::new(self.x as f32, self.y as f32, self.z as f32),
273            rotation: self.rotation,
274            scale: self.scale,
275        };
276        self.commands.borrow_mut().push(WorldCommand::SetTransform(self.id, transform));
277    }
278}
279
280/// The `world` object scripts use to reach entities other than their own:
281/// `world.find(name)` (an [`EntityHandle`], or `()` if no entity has that name — Rhai has no
282/// `Option` visible to scripts, so unit is the idiomatic "not found", the same way
283/// [`ScriptInput`]'s `is_held`/`is_pressed` treat an unrecognized key name as simply false rather
284/// than an error) and `world.spawn_entity(name, x, y, z)` (`spawn` alone is a reserved word in
285/// Rhai, even as a method name — queues a new entity, see
286/// [`WorldCommand::Spawn`] — and returns a handle to it usable immediately, since
287/// [`ScriptRuntime::drain_commands`] resolves same-batch pending spawns before anything later in
288/// the batch that references them).
289///
290/// Reads (`find`) come from the frozen [`FrameSnapshot`]; writes (`spawn`, and anything called on
291/// a handle it returns) go through the same [`WorldCommand`] queue as `entity` does — see both of
292/// those types' doc comments for why.
293#[derive(Clone, CustomType)]
294#[rhai_type(name = "World", extra = Self::register_extra)]
295struct ScriptWorld {
296    #[rhai_type(skip)]
297    frame: Rc<RefCell<FrameSnapshot>>,
298    #[rhai_type(skip)]
299    commands: Rc<RefCell<Vec<WorldCommand>>>,
300    #[rhai_type(skip)]
301    next_pending_id: Rc<Cell<u64>>,
302}
303
304impl ScriptWorld {
305    fn register_extra(builder: &mut TypeBuilder<Self>) {
306        builder
307            .with_fn("find", |w: &mut Self, name: &str| -> Dynamic {
308                let frame = w.frame.borrow();
309                let Some(&entity) = frame.by_name.get(name) else { return Dynamic::UNIT };
310                let transform = frame.transforms.get(entity).copied().unwrap_or_default();
311                let entity_name = frame.names.get(entity).map(|n| n.0.clone()).unwrap_or_default();
312                Dynamic::from(EntityHandle::new(ScriptEntityId::Real(entity), &transform, entity_name, w.commands.clone()))
313            })
314            .with_fn("spawn_entity", |w: &mut Self, name: &str, x: f64, y: f64, z: f64| -> EntityHandle {
315                let pending_id = w.next_pending_id.get();
316                w.next_pending_id.set(pending_id + 1);
317                let transform = Transform { position: Vec3::new(x as f32, y as f32, z as f32), ..Transform::default() };
318                w.commands.borrow_mut().push(WorldCommand::Spawn { pending_id, name: name.to_string(), transform });
319                EntityHandle::new(ScriptEntityId::Pending(pending_id), &transform, name.to_string(), w.commands.clone())
320            });
321    }
322}
323
324/// The `scene` object scripts use to change the loaded scene:
325/// `scene.change("scenes/level2.ron")` (project-relative path to a `.ron` [`crate::scene_file::SceneFile`]).
326/// A thin wrapper over the shared [`WorldCommand`] queue — see that type's `ChangeScene` variant
327/// and [`EntityHandle`]'s doc comment for why nothing here can mutate `World` directly.
328#[derive(Clone, CustomType)]
329#[rhai_type(name = "Scene", extra = Self::register_extra)]
330struct ScriptScene {
331    #[rhai_type(skip)]
332    commands: Rc<RefCell<Vec<WorldCommand>>>,
333}
334
335impl ScriptScene {
336    fn register_extra(builder: &mut TypeBuilder<Self>) {
337        builder.with_fn("change", |s: &mut Self, path: &str| {
338            s.commands.borrow_mut().push(WorldCommand::ChangeScene(PathBuf::from(path)));
339        });
340    }
341}
342
343/// A read-only keyboard/mouse snapshot passed as `on_update`'s second argument:
344/// `let on_update = |dt, input| { if input.is_held("KeyW") { entity.translate(0.0, 0.0, -dt); } };`.
345/// Key names match [`KeyCode`]'s own variant identifiers via [`KeyCode::from_name`] (its derived
346/// `Debug` output prints the same strings) — e.g. `"KeyW"`, `"ArrowUp"`, `"Space"`,
347/// `"ShiftLeft"`. Mouse button names match [`winit::event::MouseButton`]'s own variant
348/// identifiers via [`mouse_button_from_name`] — `"Left"`, `"Right"`, `"Middle"`, `"Back"`,
349/// `"Forward"`. An unrecognized name is simply never held/pressed rather than an error, so a
350/// typo in a key/button name fails quietly instead of aborting the script.
351///
352/// Rebuilt fresh from [`InputState`]/[`MouseState`] every [`ScriptRuntime::update_entity`] call
353/// rather than synced in/out like [`EntityHandle`] — input is read-only from a script's
354/// perspective, so there's nothing to write back.
355#[derive(Clone, CustomType)]
356#[rhai_type(name = "Input", extra = Self::register_extra)]
357struct ScriptInput {
358    #[rhai_type(skip)]
359    held: HashSet<KeyCode>,
360    #[rhai_type(skip)]
361    pressed: HashSet<KeyCode>,
362    #[rhai_type(skip)]
363    mouse_held: HashSet<winit::event::MouseButton>,
364    #[rhai_type(skip)]
365    mouse_pressed: HashSet<winit::event::MouseButton>,
366    #[rhai_type(skip)]
367    mouse_x: f64,
368    #[rhai_type(skip)]
369    mouse_y: f64,
370    /// Mouse movement since last frame — the usual building block for a mouse-look camera.
371    #[rhai_type(skip)]
372    mouse_dx: f64,
373    #[rhai_type(skip)]
374    mouse_dy: f64,
375    #[rhai_type(skip)]
376    mouse_wheel: f64,
377}
378
379impl ScriptInput {
380    fn from_state(input: &InputState, mouse: &MouseState, mouse_delta: (f32, f32)) -> Self {
381        let (mouse_x, mouse_y) = mouse.position();
382        Self {
383            held: input.keys_held().collect(),
384            pressed: input.keys_pressed().collect(),
385            mouse_held: mouse.buttons_held().collect(),
386            mouse_pressed: mouse.buttons_pressed().collect(),
387            mouse_x: mouse_x as f64,
388            mouse_y: mouse_y as f64,
389            mouse_dx: mouse_delta.0 as f64,
390            mouse_dy: mouse_delta.1 as f64,
391            mouse_wheel: mouse.wheel_delta() as f64,
392        }
393    }
394
395    fn register_extra(builder: &mut TypeBuilder<Self>) {
396        builder
397            .with_fn("is_held", |i: &mut Self, name: &str| {
398                KeyCode::from_name(name).is_some_and(|key| i.held.contains(&key))
399            })
400            .with_fn("is_pressed", |i: &mut Self, name: &str| {
401                KeyCode::from_name(name).is_some_and(|key| i.pressed.contains(&key))
402            })
403            .with_fn("is_mouse_held", |i: &mut Self, name: &str| {
404                mouse_button_from_name(name).is_some_and(|button| i.mouse_held.contains(&button))
405            })
406            .with_fn("is_mouse_pressed", |i: &mut Self, name: &str| {
407                mouse_button_from_name(name).is_some_and(|button| i.mouse_pressed.contains(&button))
408            })
409            .with_fn("mouse_x", |i: &mut Self| i.mouse_x)
410            .with_fn("mouse_y", |i: &mut Self| i.mouse_y)
411            .with_fn("mouse_dx", |i: &mut Self| i.mouse_dx)
412            .with_fn("mouse_dy", |i: &mut Self| i.mouse_dy)
413            .with_fn("mouse_wheel", |i: &mut Self| i.mouse_wheel);
414    }
415}
416
417/// Parses a mouse button name matching [`winit::event::MouseButton`]'s own variant identifiers
418/// (the same strings its derived `Debug` output prints for its unit variants) — used by
419/// [`ScriptInput`] so a `.rhai` script can reference a mouse button by name, the same way
420/// [`KeyCode::from_name`] does for the keyboard. `Other(_)` (extra vendor-specific buttons) has
421/// no name-based way to reach it, since there's no stable name for an arbitrary button index.
422fn mouse_button_from_name(name: &str) -> Option<winit::event::MouseButton> {
423    Some(match name {
424        "Left" => winit::event::MouseButton::Left,
425        "Right" => winit::event::MouseButton::Right,
426        "Middle" => winit::event::MouseButton::Middle,
427        "Back" => winit::event::MouseButton::Back,
428        "Forward" => winit::event::MouseButton::Forward,
429        _ => return None,
430    })
431}
432
433/// One error from compiling, starting, or running a script — never fatal to the editor, just
434/// something to report and move past (see [`ScriptRuntime::update_entity`]).
435#[derive(Debug)]
436pub struct ScriptError {
437    pub entity: Entity,
438    pub script: PathBuf,
439    pub message: String,
440}
441
442/// How many consecutive frames a script instance is allowed to error in [`ScriptRuntime::update_entity`]
443/// before it disables itself (in the live [`ScriptList`], not on disk) so a broken script doesn't
444/// spam the error overlay forever.
445const MAX_CONSECUTIVE_FAILURES: u32 = 5;
446
447struct ScriptInstance {
448    ast: Rc<AST>,
449    scope: Scope<'static>,
450    /// The script's `on_update` closure, if it defined one — captured once at start, but since
451    /// Rhai closures share their captured `Scope` variables by reference (see
452    /// `scripting::closure_state_spike`), calling this later still sees/mutates the same
453    /// persistent state as `scope`.
454    on_update: Option<FnPtr>,
455    consecutive_failures: u32,
456}
457
458/// Compiles and caches one [`AST`] per unique script path (shared across every entity that
459/// attaches the same script) and owns each `(entity, attachment index)`'s running Rhai `Scope`.
460/// Created when the editor's Play mode starts and dropped when it stops — nothing here is meant
461/// to survive a Stop; see the implementation plan's "Play/Stop snapshot" note for why script
462/// state resetting on Stop is load-bearing, not just convenient.
463pub struct ScriptRuntime {
464    engine: Engine,
465    compiled: HashMap<PathBuf, Rc<AST>>,
466    instances: HashMap<(Entity, usize), ScriptInstance>,
467    /// Shared with every script instance's `entity`/`world` values — see [`WorldCommand`].
468    commands: Rc<RefCell<Vec<WorldCommand>>>,
469    /// Shared with every script instance's `world` value — see [`FrameSnapshot`].
470    frame: Rc<RefCell<FrameSnapshot>>,
471    /// Shared with every script instance's `world` value, so two scripts spawning in the same
472    /// frame can't mint colliding [`ScriptEntityId::Pending`] ids.
473    next_pending_id: Rc<Cell<u64>>,
474    /// Needed to resolve `attach_script`'s project-relative path into a real file path — see
475    /// [`WorldCommand::AttachScript`]. `start_script`'s own `script_path` argument, by contrast,
476    /// always arrives already resolved (the editor does that itself before calling it).
477    project_root: PathBuf,
478}
479
480impl ScriptRuntime {
481    pub fn new(project_root: PathBuf) -> Self {
482        Self {
483            engine: Self::build_engine(),
484            compiled: HashMap::new(),
485            instances: HashMap::new(),
486            commands: Rc::new(RefCell::new(Vec::new())),
487            frame: Rc::new(RefCell::new(FrameSnapshot::default())),
488            next_pending_id: Rc::new(Cell::new(0)),
489            project_root,
490        }
491    }
492
493    fn build_engine() -> Engine {
494        let mut engine = Engine::new();
495        engine.build_type::<EntityHandle>();
496        engine.build_type::<ScriptInput>();
497        engine.build_type::<ScriptWorld>();
498        engine.build_type::<ScriptScene>();
499        engine
500    }
501
502    /// Refreshes the [`FrameSnapshot`] every `world.find(...)` call this frame will read from.
503    /// Call once per frame, before running any script that frame (see the editor's
504    /// `EditorScene::update`, `EditorMode::Playing` branch).
505    pub fn begin_frame(&mut self, world: &World) {
506        *self.frame.borrow_mut() = FrameSnapshot::capture(world);
507    }
508
509    fn script_world(&self) -> ScriptWorld {
510        ScriptWorld { frame: self.frame.clone(), commands: self.commands.clone(), next_pending_id: self.next_pending_id.clone() }
511    }
512
513    fn script_scene(&self) -> ScriptScene {
514        ScriptScene { commands: self.commands.clone() }
515    }
516
517    /// Compiles (or reuses the cached [`AST`] for) the script at `script_path`, then starts a
518    /// fresh instance of it for `(entity, index)`: runs the script body once — which defines its
519    /// top-level locals and `on_start`/`on_update` closures — and calls `on_start()` if the
520    /// script defined one. `world` supplies the entity's starting position/name so `on_start`
521    /// sees real data rather than a zeroed placeholder, and (now that it's `&mut`) lets this
522    /// method drain whatever `on_start` queues via `entity`/`world`/`scene` immediately rather
523    /// than leaving it pending — a script whose only hook is `on_start` (no `on_update` ever
524    /// runs to drain the shared queue on its behalf) would otherwise have calls like
525    /// `entity.set_persistent(true)` or `scene.change(...)` sit in the queue forever. `renderer`
526    /// is threaded through to that drain for the same reason [`Self::drain_commands`] needs one
527    /// (`entity.set_sprite`/`set_model`).
528    ///
529    /// A compile/read/body error is fatal — no instance is created and the single resulting
530    /// error is returned. An `on_start` error is not: the instance is still created (so
531    /// `on_update` still runs on later frames) and the error is only reported, not propagated as
532    /// a failure to start. Every error encountered (compile/read/body, `on_start` itself, or
533    /// anything the drain surfaces) is returned rather than the first one short-circuiting,
534    /// mirroring [`Self::update_entity`]'s own error-collection shape.
535    pub fn start_script(&mut self, world: &mut World, entity: Entity, index: usize, script_path: &Path, renderer: Option<&Renderer>) -> Vec<ScriptError> {
536        let err = |message: String| ScriptError { entity, script: script_path.to_path_buf(), message };
537
538        let ast = match self.compiled.get(script_path) {
539            Some(ast) => ast.clone(),
540            None => {
541                let source = match fs::read_to_string(script_path) {
542                    Ok(source) => source,
543                    Err(e) => return vec![err(format!("failed to read script: {e}"))],
544                };
545                let ast = match self.engine.compile(&source) {
546                    Ok(ast) => Rc::new(ast),
547                    Err(e) => return vec![err(format!("compile error: {e}"))],
548                };
549                self.compiled.insert(script_path.to_path_buf(), ast.clone());
550                ast
551            }
552        };
553
554        let mut scope = Scope::new();
555        let transform = world.transforms.get(entity).copied().unwrap_or_default();
556        let name = world.names.get(entity).map(|n| n.0.clone()).unwrap_or_default();
557        scope.push("entity", EntityHandle::new(ScriptEntityId::Real(entity), &transform, name, self.commands.clone()));
558        scope.push("world", self.script_world());
559        scope.push("scene", self.script_scene());
560
561        if let Err(e) = self.engine.run_ast_with_scope(&mut scope, &ast) {
562            return vec![err(format!("script error: {e}"))];
563        }
564
565        let on_update = scope.get_value::<FnPtr>("on_update");
566        let on_start = scope.get_value::<FnPtr>("on_start");
567
568        let mut errors: Vec<ScriptError> = on_start
569            .as_ref()
570            .and_then(|on_start| {
571                on_start.call::<rhai::Dynamic>(&self.engine, &ast, ()).err().map(|e| err(format!("on_start error: {e}")))
572            })
573            .into_iter()
574            .collect();
575
576        self.instances.insert((entity, index), ScriptInstance { ast, scope, on_update, consecutive_failures: 0 });
577
578        errors.extend(self.drain_commands(world, entity, renderer, script_path));
579        errors
580    }
581
582    /// Starts every enabled script attachment (in attachment order) that isn't already running on
583    /// an entity currently in `world` — the "start scripts" half of loading a scene, factored out
584    /// so `EditorScene::start_play`, `runtime`'s `RuntimeScene::start`, and a script-triggered
585    /// `scene.change(...)` (see [`WorldCommand::ChangeScene`]) all run it identically instead of
586    /// each keeping its own copy of this loop. The "isn't already running" check is what lets a
587    /// scene change call this over the *whole* post-change `world` (persistent entities included)
588    /// without restarting a persistent entity's already-running script and losing its state.
589    /// `base_dir` resolves each attachment's project-relative `path` into a real file path (an
590    /// editor project's root, or an exported game's `res/` directory). Caller must call
591    /// [`Self::begin_frame`] first so `world.find(...)` inside any `on_start` sees a snapshot that
592    /// includes the entities being started (see
593    /// `runtime_tests::on_start_can_read_other_entities_via_find_when_begin_frame_ran_first`).
594    /// `renderer` is forwarded to each [`Self::start_script`] call, for the same reason
595    /// [`Self::drain_commands`] needs one (an `on_start` that calls `entity.set_sprite`/
596    /// `set_model`).
597    pub fn start_all_scripts(&mut self, world: &mut World, base_dir: &Path, renderer: Option<&Renderer>) -> Vec<ScriptError> {
598        let mut errors = Vec::new();
599        for entity in world.iter_entities().collect::<Vec<_>>() {
600            let Some(list) = world.scripts.get(entity) else { continue };
601            // Skip an attachment that already has a running `ScriptInstance` — load-bearing for
602            // `WorldCommand::ChangeScene`, whose surviving persistent entities are still in
603            // `world` (and still have their `ScriptList`) but must keep their existing instance
604            // (and its accumulated state) rather than being restarted from scratch alongside the
605            // scene's actually-new entities.
606            let starts: Vec<(usize, PathBuf)> = list
607                .0
608                .iter()
609                .enumerate()
610                .filter(|(index, attachment)| attachment.enabled && !self.instances.contains_key(&(entity, *index)))
611                .map(|(index, attachment)| (index, base_dir.join(&attachment.path)))
612                .collect();
613
614            for (index, script_path) in starts {
615                errors.extend(self.start_script(world, entity, index, &script_path, renderer));
616            }
617        }
618        errors
619    }
620
621    /// Calls every started, enabled attachment's `on_update(dt, input)` closure for `entity`, in
622    /// attachment order (disabled attachments — including ones this call itself just disabled
623    /// after too many failures — are skipped), syncing the entity's [`crate::world::Transform`]
624    /// and name in beforehand and applying whatever it queued via `entity`/`world` (see
625    /// [`Self::drain_commands`]) right after. One instance erroring doesn't stop the others;
626    /// every error encountered is returned rather than the first one short-circuiting. If an
627    /// attachment despawns its own entity, the remaining attachments on that entity are skipped
628    /// for the rest of this call — there's nothing left to update.
629    ///
630    /// `renderer` is only needed for `entity.set_sprite(...)`/`set_model(...)` (loading a new
631    /// asset needs a live GPU device); pass `None` when one isn't available (e.g. in a test with
632    /// no real `Renderer`) and any such command just reports a [`ScriptError`] instead of
633    /// silently doing nothing or panicking.
634    ///
635    /// `mouse_delta` is the caller's responsibility, same as it is for
636    /// `editor::fly_camera::FlyCamera::update` — `MouseState` only tracks position, not
637    /// frame-to-frame movement, so the caller (which already diffs `MouseState::position()`
638    /// against last frame's for its own fly camera) passes it through directly.
639    pub fn update_entity(
640        &mut self,
641        world: &mut World,
642        entity: Entity,
643        dt: f32,
644        input: &InputState,
645        mouse: &MouseState,
646        mouse_delta: (f32, f32),
647        renderer: Option<&Renderer>,
648    ) -> Vec<ScriptError> {
649        let mut errors = Vec::new();
650        let Some(attachments) = world.scripts.get(entity).cloned() else { return errors };
651        let input = ScriptInput::from_state(input, mouse, mouse_delta);
652
653        for (index, attachment) in attachments.0.iter().enumerate() {
654            if !attachment.enabled {
655                continue;
656            }
657            let Some(instance) = self.instances.get_mut(&(entity, index)) else { continue };
658            let Some(on_update) = instance.on_update.clone() else { continue };
659
660            // Sync the entity's transform/name into the script's own `entity` handle in one shot.
661            if let Some(transform) = world.transforms.get(entity).copied() {
662                let name = world.names.get(entity).map(|n| n.0.as_str()).unwrap_or("");
663                if let Some(mut handle) = scope_entity_mut(&mut instance.scope) {
664                    handle.sync_from_world(&transform, name);
665                }
666            }
667
668            // Call the script's `on_update(dt, input)` closure, which may mutate its captured
669            // `Scope` variables and/or queue `WorldCommand`s via `entity`/`world`.
670            match on_update.call::<rhai::Dynamic>(&self.engine, &instance.ast, (dt as f64, input.clone())) {
671                Ok(_) => instance.consecutive_failures = 0,
672                Err(e) => {
673                    instance.consecutive_failures += 1;
674                    errors.push(ScriptError { entity, script: attachment.path.clone(), message: e.to_string() });
675
676                    if instance.consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
677                        instance.consecutive_failures = 0;
678                        if let Some(list) = world.scripts.get_mut(entity) {
679                            if let Some(attachment) = list.0.get_mut(index) {
680                                attachment.enabled = false;
681                            }
682                        }
683                    }
684                }
685            }
686
687            // Apply whatever this call queued — commands emitted before a thrown error are still
688            // valid intents and still apply, since Rhai only halts the *script*, not the queue.
689            errors.extend(self.drain_commands(world, entity, renderer, &attachment.path));
690
691            if !world.is_alive(entity) {
692                break;
693            }
694        }
695
696        errors
697    }
698
699    /// Applies every [`WorldCommand`] queued since the last drain, in emission order, resolving
700    /// any [`ScriptEntityId::Pending`] against `Spawn` commands earlier in this same batch. This
701    /// is the only place `World` is actually mutated on a script's behalf — nothing reachable
702    /// from inside the Rhai call itself can hold `world` (see [`WorldCommand`]'s doc comment).
703    /// `renderer` is threaded through for `SetSprite`/`SetModel` (see [`Self::update_entity`]'s
704    /// doc comment); `source_script` attributes any error that isn't more specifically
705    /// attributable (e.g. an `AttachScript` failure already carries its own script path).
706    /// `entity` attributes a `ChangeScene` error, which has no `ScriptEntityId` of its own (it's
707    /// issued via the entity-less `scene` global, not a handle to a specific entity).
708    fn drain_commands(&mut self, world: &mut World, entity: Entity, renderer: Option<&Renderer>, source_script: &Path) -> Vec<ScriptError> {
709        let commands: Vec<WorldCommand> = self.commands.borrow_mut().drain(..).collect();
710        let mut pending: HashMap<u64, Entity> = HashMap::new();
711        let mut errors = Vec::new();
712
713        let resolve = |id: ScriptEntityId, pending: &HashMap<u64, Entity>| match id {
714            ScriptEntityId::Real(entity) => Some(entity),
715            ScriptEntityId::Pending(pending_id) => pending.get(&pending_id).copied(),
716        };
717
718        for command in commands {
719            match command {
720                WorldCommand::Spawn { pending_id, name, transform } => {
721                    let entity = world.spawn_empty(name, transform);
722                    pending.insert(pending_id, entity);
723                }
724                WorldCommand::SetTransform(id, transform) => {
725                    if let Some(entity) = resolve(id, &pending) {
726                        if let Some(slot) = world.transforms.get_mut(entity) {
727                            *slot = transform;
728                        }
729                    }
730                }
731                WorldCommand::Rename(id, name) => {
732                    if let Some(entity) = resolve(id, &pending) {
733                        if let Some(slot) = world.names.get_mut(entity) {
734                            slot.0 = name;
735                        }
736                    }
737                }
738                WorldCommand::Despawn(id) => {
739                    if let Some(entity) = resolve(id, &pending) {
740                        world.despawn(entity);
741                        self.instances.retain(|(e, _), _| *e != entity);
742                    }
743                }
744                WorldCommand::AttachScript(id, path) => {
745                    if let Some(entity) = resolve(id, &pending) {
746                        let full_path = self.project_root.join(&path);
747                        let new_index = match world.scripts.get_mut(entity) {
748                            Some(list) => {
749                                list.0.push(ScriptAttachment { path, enabled: true });
750                                list.0.len() - 1
751                            }
752                            None => {
753                                world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path, enabled: true }]));
754                                0
755                            }
756                        };
757                        // Start it right away so it actually runs this same Play session,
758                        // instead of sitting inert until the next Stop/Play like an attachment
759                        // made through the Inspector UI does today.
760                        errors.extend(self.start_script(world, entity, new_index, &full_path, renderer));
761                    }
762                }
763                WorldCommand::SetScriptEnabled(id, index, enabled) => {
764                    if let Some(entity) = resolve(id, &pending) {
765                        if let Some(list) = world.scripts.get_mut(entity) {
766                            if let Some(attachment) = list.0.get_mut(index) {
767                                attachment.enabled = enabled;
768                            }
769                        }
770                    }
771                }
772                WorldCommand::SetSprite(id, path) => {
773                    if let Some(entity) = resolve(id, &pending) {
774                        let Some(renderer) = renderer else {
775                            errors.push(no_renderer_error(entity, source_script, &path));
776                            continue;
777                        };
778                        let full_path = self.project_root.join(&path);
779                        match Texture::from_path(renderer, &full_path) {
780                            Ok(texture) => {
781                                let mut sprite = Sprite::new(Arc::new(texture));
782                                sprite.fit_within_unit_square();
783                                world.set_renderable(entity, Renderable::Sprite(sprite));
784                            }
785                            Err(e) => errors.push(ScriptError {
786                                entity,
787                                script: source_script.to_path_buf(),
788                                message: format!("failed to load sprite {}: {e}", path.display()),
789                            }),
790                        }
791                    }
792                }
793                WorldCommand::SetModel(id, path) => {
794                    if let Some(entity) = resolve(id, &pending) {
795                        let Some(renderer) = renderer else {
796                            errors.push(no_renderer_error(entity, source_script, &path));
797                            continue;
798                        };
799                        let full_path = self.project_root.join(&path);
800                        match Model::load(renderer, &full_path) {
801                            Ok(model) => world.set_renderable(entity, Renderable::Model(model)),
802                            Err(e) => errors.push(ScriptError {
803                                entity,
804                                script: source_script.to_path_buf(),
805                                message: format!("failed to load model {}: {e}", path.display()),
806                            }),
807                        }
808                    }
809                }
810                WorldCommand::ClearRenderable(id) => {
811                    if let Some(entity) = resolve(id, &pending) {
812                        world.clear_renderable(entity);
813                    }
814                }
815                WorldCommand::SetPersistent(id, persistent) => {
816                    if let Some(target) = resolve(id, &pending) {
817                        world.set_persistent(target, persistent);
818                    }
819                }
820                WorldCommand::ChangeScene(path) => {
821                    let full_path = self.project_root.join(&path);
822                    match crate::scene_file::SceneFile::load(&full_path) {
823                        Ok(scene_file) => {
824                            world.despawn_non_persistent();
825                            self.instances.retain(|(e, _), _| world.is_alive(*e));
826
827                            // Clone project_root into a local first: start_all_scripts/begin_frame
828                            // both need `&mut self` right below, so `&self.project_root` can't be
829                            // borrowed alongside them.
830                            let base_dir = self.project_root.clone();
831                            for record in scene_file.entities {
832                                record.spawn_into(world, renderer, &base_dir);
833                            }
834                            self.begin_frame(world);
835                            errors.extend(self.start_all_scripts(world, &base_dir, renderer));
836                        }
837                        Err(e) => errors.push(ScriptError {
838                            entity,
839                            script: source_script.to_path_buf(),
840                            message: format!("failed to load scene {}: {e}", path.display()),
841                        }),
842                    }
843                }
844            }
845        }
846
847        errors
848    }
849}
850
851fn no_renderer_error(entity: Entity, source_script: &Path, asset_path: &Path) -> ScriptError {
852    ScriptError {
853        entity,
854        script: source_script.to_path_buf(),
855        message: format!("cannot load {} — no renderer available", asset_path.display()),
856    }
857}
858
859/// Borrows the `entity` variable out of a script instance's `Scope` as `&mut EntityHandle`,
860/// working whether or not it's been promoted to a shared value by closure capture (see
861/// `scripting::closure_state_spike`).
862fn scope_entity_mut<'a>(scope: &'a mut Scope<'static>) -> Option<rhai::DynamicWriteLock<'a, EntityHandle>> {
863    scope.get_mut("entity")?.write_lock::<EntityHandle>()
864}
865
866#[cfg(test)]
867mod closure_state_spike {
868    //! Throwaway spike (see the implementation plan's "persistent script-local state" risk):
869    //! confirms that a Rhai closure assigned to a `let` variable captures outer `Scope`
870    //! variables *by reference*, so both a plain numeric counter and a custom-type "entity"
871    //! object mutated inside the closure stay visible/persistent across repeated calls. If this
872    //! stops holding on a future `rhai` upgrade, `ScriptRuntime` needs to fall back to plain
873    //! `fn on_update(dt)` with state smuggled through the entity's own Transform instead.
874    use rhai::{Engine, FnPtr, Scope};
875
876    #[derive(Clone)]
877    struct SpikeEntity {
878        x: f64,
879    }
880
881    #[test]
882    fn closure_captures_scope_variables_by_reference() {
883        let mut engine = Engine::new();
884        engine
885            .register_type_with_name::<SpikeEntity>("Entity")
886            .register_get_set("x", |e: &mut SpikeEntity| e.x, |e: &mut SpikeEntity, v: f64| e.x = v);
887
888        let mut scope = Scope::new();
889        scope.push("entity", SpikeEntity { x: 0.0 });
890
891        let ast = engine
892            .compile(
893                r#"
894                let counter = 0;
895                let on_update = |dt| {
896                    counter += 1;
897                    entity.x += dt;
898                    counter
899                };
900                "#,
901            )
902            .expect("script should compile");
903
904        engine.run_ast_with_scope(&mut scope, &ast).expect("script body should run");
905
906        let on_update: FnPtr = scope.get_value("on_update").expect("on_update closure should be in scope");
907
908        let first: i64 = on_update.call(&engine, &ast, (1.5_f64,)).expect("first call should succeed");
909        let second: i64 = on_update.call(&engine, &ast, (1.5_f64,)).expect("second call should succeed");
910
911        assert_eq!(first, 1, "counter should persist across calls (closure captures by reference)");
912        assert_eq!(second, 2);
913
914        let entity: SpikeEntity = scope.get_value("entity").expect("entity should still be in scope");
915        assert_eq!(entity.x, 3.0, "mutations inside the closure should be visible via the original Scope variable");
916    }
917}
918
919#[cfg(test)]
920mod runtime_tests {
921    use super::*;
922    use crate::camera::Camera;
923    use crate::world::Transform;
924
925    fn test_camera() -> Camera {
926        Camera { position: Vec3::ZERO, yaw: 0.0, pitch: 0.0, aspect: 1.0, fov: 45.0, znear: 0.1, zfar: 100.0 }
927    }
928
929    /// An [`InputState`] with nothing held/pressed, for tests that don't care about input.
930    fn no_input() -> InputState {
931        InputState::for_test([], [])
932    }
933
934    /// A [`MouseState`] with nothing held/pressed/moved, for tests that don't care about mouse
935    /// input.
936    fn no_mouse() -> MouseState {
937        MouseState::new()
938    }
939
940    fn test_runtime() -> ScriptRuntime {
941        ScriptRuntime::new(PathBuf::new())
942    }
943
944    /// Writes a `.rhai` source string to a fresh temp file and returns its path, so
945    /// [`ScriptRuntime::start_script`] (which reads scripts from disk, like the real editor
946    /// will) has something real to compile.
947    fn write_script(name: &str, source: &str) -> PathBuf {
948        let dir = std::env::temp_dir().join("libdqg_scripting_tests");
949        fs::create_dir_all(&dir).unwrap();
950        let path = dir.join(name);
951        fs::write(&path, source).unwrap();
952        path
953    }
954
955    /// Serializes `scene_file` to a fresh temp `.ron` file and returns its path, so
956    /// `WorldCommand::ChangeScene`'s handling (which reads a scene file from disk, like the real
957    /// editor/runtime will) has something real to load.
958    fn write_scene(name: &str, scene_file: &crate::scene_file::SceneFile) -> PathBuf {
959        let dir = std::env::temp_dir().join("libdqg_scripting_tests");
960        fs::create_dir_all(&dir).unwrap();
961        let path = dir.join(name);
962        scene_file.save(&path).unwrap();
963        path
964    }
965
966    #[test]
967    fn on_update_translates_entity_and_persists_state_across_frames() {
968        let mut world = World::new(test_camera());
969        let entity = world.spawn_empty("Mover", Transform::default());
970        let script_path = write_script(
971            "mover.rhai",
972            r#"
973            let speed = 1.0;
974            let on_update = |dt, input| {
975                entity.translate(speed * dt, 0.0, 0.0);
976            };
977            "#,
978        );
979        world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
980
981        let mut runtime = test_runtime();
982        assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
983
984        let input = no_input();
985        assert!(runtime.update_entity(&mut world, entity, 0.5, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
986        assert!(runtime.update_entity(&mut world, entity, 0.5, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
987
988        let position = world.transforms.get(entity).unwrap().position;
989        assert!((position.x - 1.0).abs() < 1e-5, "expected x\u{2248}1.0 after two 0.5s updates, got {position:?}");
990    }
991
992    #[test]
993    fn repeated_runtime_errors_disable_the_attachment() {
994        let mut world = World::new(test_camera());
995        let entity = world.spawn_empty("Broken", Transform::default());
996        let script_path = write_script("broken.rhai", "let on_update = |dt, input| { throw \"boom\"; };");
997        world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
998
999        let mut runtime = test_runtime();
1000        assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1001
1002        let input = no_input();
1003        for _ in 0..MAX_CONSECUTIVE_FAILURES {
1004            let errors = runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None);
1005            assert_eq!(errors.len(), 1);
1006        }
1007
1008        let attachment_enabled = world.scripts.get(entity).unwrap().0[0].enabled;
1009        assert!(!attachment_enabled, "attachment should auto-disable after MAX_CONSECUTIVE_FAILURES errors");
1010
1011        // Disabled, so no further errors should be produced even though the script still throws.
1012        assert!(runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1013    }
1014
1015    #[test]
1016    fn on_update_sees_held_and_pressed_keys() {
1017        let mut world = World::new(test_camera());
1018        let entity = world.spawn_empty("Listener", Transform::default());
1019        let script_path = write_script(
1020            "input.rhai",
1021            r#"
1022            let on_update = |dt, input| {
1023                if input.is_held("KeyW") {
1024                    entity.translate(0.0, 0.0, -1.0);
1025                }
1026                if input.is_pressed("Space") {
1027                    entity.translate(1.0, 0.0, 0.0);
1028                }
1029                // An unrecognized key name should just read as not held/pressed, not error.
1030                if input.is_held("NotAKey") {
1031                    entity.translate(0.0, 99.0, 0.0);
1032                }
1033            };
1034            "#,
1035        );
1036        world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1037
1038        let mut runtime = test_runtime();
1039        assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1040
1041        let input = InputState::for_test([KeyCode::KeyW], [KeyCode::Space]);
1042        assert!(runtime.update_entity(&mut world, entity, 1.0, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1043
1044        let position = world.transforms.get(entity).unwrap().position;
1045        assert!(
1046            position.abs_diff_eq(Vec3::new(1.0, 0.0, -1.0), 1e-5),
1047            "expected held KeyW and pressed Space to both apply, got {position:?}"
1048        );
1049    }
1050
1051    #[test]
1052    fn on_update_sees_mouse_state() {
1053        let mut world = World::new(test_camera());
1054        let entity = world.spawn_empty("MouseListener", Transform::default());
1055        let script_path = write_script(
1056            "mouse.rhai",
1057            r#"
1058            let on_update = |dt, input| {
1059                if input.is_mouse_held("Left") {
1060                    entity.translate(0.0, 0.0, -1.0);
1061                }
1062                if input.is_mouse_pressed("Right") {
1063                    entity.translate(1.0, 0.0, 0.0);
1064                }
1065                entity.translate(input.mouse_dx() * 0.1, input.mouse_dy() * 0.1, 0.0);
1066                // An unrecognized button name should just read as not held/pressed, not error.
1067                if input.is_mouse_held("NotAButton") {
1068                    entity.translate(0.0, 99.0, 0.0);
1069                }
1070            };
1071            "#,
1072        );
1073        world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1074
1075        let mut runtime = test_runtime();
1076        assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1077
1078        let mouse = MouseState::for_test(
1079            (0.0, 0.0),
1080            [winit::event::MouseButton::Left],
1081            [winit::event::MouseButton::Right],
1082            0.0,
1083        );
1084        assert!(runtime.update_entity(&mut world, entity, 1.0, &no_input(), &mouse, (5.0, 2.0), None).is_empty());
1085
1086        let position = world.transforms.get(entity).unwrap().position;
1087        assert!(
1088            position.abs_diff_eq(Vec3::new(1.5, 0.2, -1.0), 1e-5),
1089            "expected held Left, pressed Right, and mouse delta to all apply, got {position:?}"
1090        );
1091    }
1092
1093    /// Regression test for a gimbal-lock bug: `update_entity` used to sync rotation into the
1094    /// script's entity handle by decomposing the entity's `Quat` to Euler angles every frame
1095    /// (`to_euler`/`from_euler` round-tripped each call), which visibly stalled a continuous
1096    /// single-axis rotation once it crossed 90° (the middle Euler axis is extracted via `asin`,
1097    /// capped at ±90°). `rotation` is now carried as a `Quat` end to end (see
1098    /// [`EntityHandle::rotation`]'s doc comment), so accumulating well past 90° must still
1099    /// produce the correct final orientation.
1100    #[test]
1101    fn on_update_rotation_does_not_stall_past_ninety_degrees() {
1102        let mut world = World::new(test_camera());
1103        let entity = world.spawn_empty("Spinner", Transform::default());
1104        let script_path = write_script(
1105            "spin.rhai",
1106            r#"
1107            let speed = 1.0;
1108            let on_update = |dt, input| {
1109                entity.rotate(0.0, speed * dt, 0.0);
1110            };
1111            "#,
1112        );
1113        world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1114
1115        let mut runtime = test_runtime();
1116        assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1117
1118        // Accumulate a full half turn (pi radians) about Y in small per-frame steps.
1119        let input = no_input();
1120        let steps = 200;
1121        let dt = std::f32::consts::PI / steps as f32;
1122        for _ in 0..steps {
1123            assert!(runtime.update_entity(&mut world, entity, dt, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1124        }
1125
1126        let rotation = world.transforms.get(entity).unwrap().rotation;
1127        let rotated_x = rotation * Vec3::X;
1128        assert!(
1129            rotated_x.abs_diff_eq(-Vec3::X, 1e-3),
1130            "expected a 180\u{b0} Y rotation to flip +X to -X, got {rotated_x:?} (rotation stalled around 90\u{b0}?)"
1131        );
1132    }
1133
1134    #[test]
1135    fn look_at_faces_the_target_along_local_negative_z() {
1136        let mut world = World::new(test_camera());
1137        let looker = world.spawn_empty("Looker", Transform { position: Vec3::new(5.0, 0.0, 0.0), ..Transform::default() });
1138        let script_path = write_script(
1139            "look_at.rhai",
1140            r#"
1141            let on_update = |dt, input| {
1142                entity.look_at(0.0, 0.0, 0.0);
1143            };
1144            "#,
1145        );
1146        world.scripts.insert(looker, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1147
1148        let mut runtime = test_runtime();
1149        assert!(runtime.start_script(&mut world, looker, 0, &script_path, None).is_empty(), "script should start");
1150        assert!(runtime.update_entity(&mut world, looker, 0.1, &no_input(), &no_mouse(), (0.0, 0.0), None).is_empty());
1151
1152        let rotation = world.transforms.get(looker).unwrap().rotation;
1153        let forward = rotation * Vec3::NEG_Z;
1154        assert!(
1155            forward.abs_diff_eq(Vec3::NEG_X, 1e-4),
1156            "expected the entity at (5,0,0) to face back toward the origin along -X, got forward={forward:?}"
1157        );
1158    }
1159
1160    /// Regression test: `world.find(...)` reads from the [`FrameSnapshot`] `begin_frame` last
1161    /// captured, but `on_start` used to be reachable (via `start_script`) *before* any
1162    /// `begin_frame` call ever ran, so `world.find` inside an `on_start` hook always saw the
1163    /// default-empty snapshot and returned `()` no matter what. The fix is at the call site
1164    /// (`editor::EditorScene::start_play` now calls `begin_frame` before its `start_script`
1165    /// loop), but the contract belongs to `ScriptRuntime` — this locks in that calling
1166    /// `begin_frame` before `start_script` is sufficient for `on_start` to see other entities.
1167    #[test]
1168    fn on_start_can_read_other_entities_via_find_when_begin_frame_ran_first() {
1169        let mut world = World::new(test_camera());
1170        world.spawn_empty("Target", Transform { position: Vec3::new(3.0, 0.0, 0.0), ..Transform::default() });
1171        let reader = world.spawn_empty("Reader", Transform::default());
1172        let script_path = write_script(
1173            "read_other_on_start.rhai",
1174            r#"
1175            let on_start = || {
1176                // If `world.find` saw the default-empty snapshot (the bug this test guards
1177                // against), `other` would be `()` and `.x` would throw here, failing this
1178                // `on_start` call.
1179                let other = world.find("Target");
1180                let x = other.x;
1181            };
1182            "#,
1183        );
1184        world.scripts.insert(reader, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1185
1186        let mut runtime = test_runtime();
1187        runtime.begin_frame(&world);
1188
1189        assert!(runtime.start_script(&mut world, reader, 0, &script_path, None).is_empty());
1190    }
1191
1192    #[test]
1193    fn on_update_reads_another_entitys_position_via_find() {
1194        let mut world = World::new(test_camera());
1195        world.spawn_empty("Target", Transform { position: Vec3::new(3.0, 0.0, 0.0), ..Transform::default() });
1196        let reader = world.spawn_empty("Reader", Transform::default());
1197        let script_path = write_script(
1198            "read_other.rhai",
1199            r#"
1200            let on_update = |dt, input| {
1201                let other = world.find("Target");
1202                entity.x = other.x;
1203            };
1204            "#,
1205        );
1206        world.scripts.insert(reader, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1207
1208        let mut runtime = test_runtime();
1209        assert!(runtime.start_script(&mut world, reader, 0, &script_path, None).is_empty(), "script should start");
1210        runtime.begin_frame(&world);
1211
1212        let input = no_input();
1213        assert!(runtime.update_entity(&mut world, reader, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1214
1215        assert_eq!(world.transforms.get(reader).unwrap().position.x, 3.0);
1216    }
1217
1218    #[test]
1219    fn on_update_writes_another_entitys_position_via_find() {
1220        let mut world = World::new(test_camera());
1221        let target = world.spawn_empty("Target", Transform::default());
1222        let mover = world.spawn_empty("Mover", Transform::default());
1223        let script_path = write_script(
1224            "move_other.rhai",
1225            r#"
1226            let on_update = |dt, input| {
1227                world.find("Target").translate(1.0, 0.0, 0.0);
1228            };
1229            "#,
1230        );
1231        world.scripts.insert(mover, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1232
1233        let mut runtime = test_runtime();
1234        assert!(runtime.start_script(&mut world, mover, 0, &script_path, None).is_empty(), "script should start");
1235        runtime.begin_frame(&world);
1236
1237        let input = no_input();
1238        assert!(runtime.update_entity(&mut world, mover, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1239
1240        assert_eq!(world.transforms.get(target).unwrap().position.x, 1.0);
1241    }
1242
1243    #[test]
1244    fn find_on_a_missing_name_returns_unit_rather_than_erroring() {
1245        let mut world = World::new(test_camera());
1246        let entity = world.spawn_empty("Lonely", Transform::default());
1247        let script_path = write_script(
1248            "find_missing.rhai",
1249            r#"
1250            let on_update = |dt, input| {
1251                let missing = world.find("NoSuchEntity");
1252                if missing == () {
1253                    entity.x = 42.0;
1254                }
1255            };
1256            "#,
1257        );
1258        world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1259
1260        let mut runtime = test_runtime();
1261        assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1262        runtime.begin_frame(&world);
1263
1264        let input = no_input();
1265        assert!(runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1266
1267        assert_eq!(world.transforms.get(entity).unwrap().position.x, 42.0);
1268    }
1269
1270    #[test]
1271    fn cross_entity_reads_are_frozen_at_frame_start() {
1272        // A moves itself, then B reads A's position via find() in the same frame: B should see
1273        // A's position as of the start of the frame, not A's already-applied move.
1274        let mut world = World::new(test_camera());
1275        let a = world.spawn_empty("A", Transform::default());
1276        let b = world.spawn_empty("B", Transform::default());
1277        let a_script = write_script(
1278            "a_moves.rhai",
1279            r#"let on_update = |dt, input| { entity.translate(10.0, 0.0, 0.0); };"#,
1280        );
1281        let b_script = write_script(
1282            "b_reads_a.rhai",
1283            r#"let on_update = |dt, input| { entity.x = world.find("A").x; };"#,
1284        );
1285        world.scripts.insert(a, ScriptList(vec![ScriptAttachment { path: a_script.clone(), enabled: true }]));
1286        world.scripts.insert(b, ScriptList(vec![ScriptAttachment { path: b_script.clone(), enabled: true }]));
1287
1288        let mut runtime = test_runtime();
1289        assert!(runtime.start_script(&mut world, a, 0, &a_script, None).is_empty(), "a should start");
1290        assert!(runtime.start_script(&mut world, b, 0, &b_script, None).is_empty(), "b should start");
1291        runtime.begin_frame(&world);
1292
1293        let input = no_input();
1294        assert!(runtime.update_entity(&mut world, a, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1295        assert!(runtime.update_entity(&mut world, b, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1296
1297        assert_eq!(world.transforms.get(a).unwrap().position.x, 10.0, "A should have moved");
1298        assert_eq!(
1299            world.transforms.get(b).unwrap().position.x,
1300            0.0,
1301            "B's read of A via find() should reflect A's position at the start of the frame, not A's move this same frame"
1302        );
1303    }
1304
1305    #[test]
1306    fn on_update_despawns_another_entity() {
1307        let mut world = World::new(test_camera());
1308        let target = world.spawn_empty("Target", Transform::default());
1309        let killer = world.spawn_empty("Killer", Transform::default());
1310        let script_path = write_script(
1311            "despawn_other.rhai",
1312            r#"let on_update = |dt, input| { world.find("Target").despawn(); };"#,
1313        );
1314        world.scripts.insert(killer, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1315
1316        let mut runtime = test_runtime();
1317        assert!(runtime.start_script(&mut world, killer, 0, &script_path, None).is_empty(), "script should start");
1318        runtime.begin_frame(&world);
1319
1320        let input = no_input();
1321        assert!(runtime.update_entity(&mut world, killer, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1322
1323        assert!(!world.is_alive(target));
1324    }
1325
1326    #[test]
1327    fn self_despawn_stops_remaining_attachments_this_frame() {
1328        let mut world = World::new(test_camera());
1329        let entity = world.spawn_empty("SelfDestruct", Transform::default());
1330        let despawn_script = write_script("despawn_self.rhai", r#"let on_update = |dt, input| { entity.despawn(); };"#);
1331        let mover_script = write_script(
1332            "mover_after_despawn.rhai",
1333            r#"let on_update = |dt, input| { entity.translate(1.0, 0.0, 0.0); };"#,
1334        );
1335        world.scripts.insert(
1336            entity,
1337            ScriptList(vec![
1338                ScriptAttachment { path: despawn_script.clone(), enabled: true },
1339                ScriptAttachment { path: mover_script.clone(), enabled: true },
1340            ]),
1341        );
1342
1343        let mut runtime = test_runtime();
1344        assert!(runtime.start_script(&mut world, entity, 0, &despawn_script, None).is_empty(), "script 0 should start");
1345        assert!(runtime.start_script(&mut world, entity, 1, &mover_script, None).is_empty(), "script 1 should start");
1346        runtime.begin_frame(&world);
1347
1348        let input = no_input();
1349        assert!(runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1350
1351        assert!(!world.is_alive(entity), "entity should be despawned");
1352    }
1353
1354    #[test]
1355    fn world_spawn_returns_a_handle_usable_in_the_same_call() {
1356        let mut world = World::new(test_camera());
1357        let spawner = world.spawn_empty("Spawner", Transform::default());
1358        let script_path = write_script(
1359            "spawn_and_move.rhai",
1360            r#"
1361            let on_update = |dt, input| {
1362                let bullet = world.spawn_entity("Bullet", 1.0, 2.0, 3.0);
1363                bullet.translate(1.0, 0.0, 0.0);
1364            };
1365            "#,
1366        );
1367        world.scripts.insert(spawner, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1368
1369        let mut runtime = test_runtime();
1370        assert!(runtime.start_script(&mut world, spawner, 0, &script_path, None).is_empty(), "script should start");
1371        runtime.begin_frame(&world);
1372
1373        let input = no_input();
1374        assert!(runtime.update_entity(&mut world, spawner, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1375
1376        let spawned = world
1377            .iter_entities()
1378            .find(|&e| e != spawner && world.names.get(e).is_some_and(|n| n.0 == "Bullet"))
1379            .expect("a Bullet entity should have been spawned");
1380        let position = world.transforms.get(spawned).unwrap().position;
1381        assert!(
1382            position.abs_diff_eq(Vec3::new(2.0, 2.0, 3.0), 1e-5),
1383            "expected spawn position (1,2,3) plus a same-call translate(1,0,0), got {position:?}"
1384        );
1385    }
1386
1387    #[test]
1388    fn on_update_renames_self_and_another_entity() {
1389        let mut world = World::new(test_camera());
1390        let other = world.spawn_empty("Old Name", Transform::default());
1391        let renamer = world.spawn_empty("Renamer", Transform::default());
1392        let script_path = write_script(
1393            "rename.rhai",
1394            r#"
1395            let on_update = |dt, input| {
1396                entity.set_name("New Renamer Name");
1397                world.find("Old Name").set_name("New Name");
1398            };
1399            "#,
1400        );
1401        world.scripts.insert(renamer, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1402
1403        let mut runtime = test_runtime();
1404        assert!(runtime.start_script(&mut world, renamer, 0, &script_path, None).is_empty(), "script should start");
1405        runtime.begin_frame(&world);
1406
1407        let input = no_input();
1408        assert!(runtime.update_entity(&mut world, renamer, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1409
1410        assert_eq!(world.names.get(renamer).unwrap().0, "New Renamer Name");
1411        assert_eq!(world.names.get(other).unwrap().0, "New Name");
1412    }
1413
1414    #[test]
1415    fn attach_script_starts_running_within_the_same_session() {
1416        let mut world = World::new(test_camera());
1417        let entity = world.spawn_empty("LateBloomer", Transform::default());
1418        let mover_script = write_script("late_mover.rhai", r#"let on_update = |dt, input| { entity.translate(1.0, 0.0, 0.0); };"#);
1419        let attacher_script = write_script(
1420            "attacher.rhai",
1421            &format!(
1422                r#"let on_update = |dt, input| {{ entity.attach_script("{}"); }};"#,
1423                mover_script.display().to_string().replace('\\', "\\\\")
1424            ),
1425        );
1426        world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: attacher_script.clone(), enabled: true }]));
1427
1428        let mut runtime = test_runtime();
1429        assert!(runtime.start_script(&mut world, entity, 0, &attacher_script, None).is_empty(), "attacher should start");
1430        runtime.begin_frame(&world);
1431
1432        let input = no_input();
1433        // First frame: the attacher queues attach_script, which is applied (and started) during
1434        // this same update_entity call.
1435        assert!(runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1436        assert_eq!(
1437            world.scripts.get(entity).unwrap().0.len(),
1438            2,
1439            "the attached mover script should now be in the entity's ScriptList"
1440        );
1441
1442        // Second frame: the newly-attached mover script should actually run now, not just sit
1443        // there inert until a hypothetical Stop/Play.
1444        runtime.begin_frame(&world);
1445        assert!(runtime.update_entity(&mut world, entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1446        assert!(
1447            world.transforms.get(entity).unwrap().position.x > 0.0,
1448            "the attached script should have moved the entity by its second frame"
1449        );
1450    }
1451
1452    // `set_sprite`/`set_model` need a live `Renderer` (a real GPU device) to actually load
1453    // anything, which a unit test has no portable way to stand up — see
1454    // `world::tests::clone_is_independent_of_the_original`'s doc comment for the same
1455    // constraint. What *is* testable without one is the graceful-failure path (`renderer: None`
1456    // reports a `ScriptError` instead of panicking or silently doing nothing) and
1457    // `detach_renderable`, which touches no GPU state at all.
1458
1459    #[test]
1460    fn set_sprite_without_a_renderer_reports_an_error_instead_of_panicking() {
1461        let mut world = World::new(test_camera());
1462        let entity = world.spawn_empty("NeedsASprite", Transform::default());
1463        let script_path = write_script(
1464            "set_sprite.rhai",
1465            r#"let on_update = |dt, input| { entity.set_sprite("assets/textures/missing.png"); };"#,
1466        );
1467        world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1468
1469        let mut runtime = test_runtime();
1470        assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1471        runtime.begin_frame(&world);
1472
1473        let errors = runtime.update_entity(&mut world, entity, 0.1, &no_input(), &no_mouse(), (0.0, 0.0), None);
1474        assert_eq!(errors.len(), 1, "expected one error reporting the missing renderer, got {errors:?}");
1475        assert!(world.renderables.get(entity).is_none(), "nothing should have been attached");
1476    }
1477
1478    #[test]
1479    fn set_model_without_a_renderer_reports_an_error_instead_of_panicking() {
1480        let mut world = World::new(test_camera());
1481        let entity = world.spawn_empty("NeedsAModel", Transform::default());
1482        let script_path = write_script(
1483            "set_model.rhai",
1484            r#"let on_update = |dt, input| { entity.set_model("assets/models/missing.obj"); };"#,
1485        );
1486        world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1487
1488        let mut runtime = test_runtime();
1489        assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1490        runtime.begin_frame(&world);
1491
1492        let errors = runtime.update_entity(&mut world, entity, 0.1, &no_input(), &no_mouse(), (0.0, 0.0), None);
1493        assert_eq!(errors.len(), 1, "expected one error reporting the missing renderer, got {errors:?}");
1494        assert!(world.renderables.get(entity).is_none(), "nothing should have been attached");
1495    }
1496
1497    #[test]
1498    fn detach_renderable_needs_no_renderer() {
1499        let mut world = World::new(test_camera());
1500        let entity = world.spawn_empty("MaybeVisible", Transform::default());
1501        let script_path = write_script("detach.rhai", r#"let on_update = |dt, input| { entity.detach_renderable(); };"#);
1502        world.scripts.insert(entity, ScriptList(vec![ScriptAttachment { path: script_path.clone(), enabled: true }]));
1503
1504        let mut runtime = test_runtime();
1505        assert!(runtime.start_script(&mut world, entity, 0, &script_path, None).is_empty(), "script should start");
1506        runtime.begin_frame(&world);
1507
1508        // No renderable was ever attached, so this also doubles as a no-op-if-absent check —
1509        // the point is that it doesn't error or panic for lack of a renderer, unlike set_sprite/
1510        // set_model above.
1511        assert!(runtime.update_entity(&mut world, entity, 0.1, &no_input(), &no_mouse(), (0.0, 0.0), None).is_empty());
1512        assert!(world.renderables.get(entity).is_none());
1513    }
1514
1515    #[test]
1516    fn scene_change_tears_down_non_persistent_entities_but_keeps_persistent_ones_running() {
1517        use crate::scene_file::{EntityRecord, SceneFile};
1518
1519        let mut world = World::new(test_camera());
1520        let persistent_entity = world.spawn_empty("Persistent", Transform::default());
1521        let transient_entity = world.spawn_empty("Transient", Transform::default());
1522        let trigger_entity = world.spawn_empty("Trigger", Transform::default());
1523
1524        // Marks itself persistent from `on_start`, then counts frames via `on_update` — the
1525        // counter is the script's own Rhai-local state, which should survive the scene change
1526        // untouched (not reset/restarted) if persistence works as intended.
1527        let persistent_script = write_script(
1528            "persistent.rhai",
1529            r#"
1530            let counter = 0.0;
1531            let on_start = || { entity.set_persistent(true); };
1532            let on_update = |dt, input| {
1533                counter += 1.0;
1534                entity.x = counter;
1535            };
1536            "#,
1537        );
1538        world.scripts.insert(
1539            persistent_entity,
1540            ScriptList(vec![ScriptAttachment { path: persistent_script.clone(), enabled: true }]),
1541        );
1542
1543        // The entity the new scene spawns; its `on_start` marks it, so we can confirm it actually
1544        // started (rather than just being inert data copied into `World`).
1545        let new_entity_script = write_script(
1546            "new_entity.rhai",
1547            r#"let on_start = || { entity.set_name("Started"); };"#,
1548        );
1549        let target_scene = write_scene(
1550            "target.ron",
1551            &SceneFile {
1552                entities: vec![EntityRecord {
1553                    name: "NewEntity".to_string(),
1554                    transform: Transform::default(),
1555                    renderable: None,
1556                    scripts: vec![ScriptAttachment { path: new_entity_script.clone(), enabled: true }],
1557                    camera: None,
1558                }],
1559            },
1560        );
1561
1562        let trigger_script = write_script(
1563            "trigger.rhai",
1564            &format!(
1565                r#"let on_update = |dt, input| {{ scene.change("{}"); }};"#,
1566                target_scene.display().to_string().replace('\\', "\\\\")
1567            ),
1568        );
1569        world.scripts.insert(trigger_entity, ScriptList(vec![ScriptAttachment { path: trigger_script.clone(), enabled: true }]));
1570
1571        let mut runtime = test_runtime();
1572        // `start_script` now drains whatever `on_start` queues immediately (see its doc comment),
1573        // so `set_persistent(true)` takes effect right here rather than needing a later
1574        // `update_entity` call to flush it.
1575        assert!(runtime.start_script(&mut world, persistent_entity, 0, &persistent_script, None).is_empty(), "persistent script should start");
1576        assert!(world.is_persistent(persistent_entity), "entity.set_persistent(true) from on_start should have applied immediately");
1577        assert!(runtime.start_script(&mut world, trigger_entity, 0, &trigger_script, None).is_empty(), "trigger script should start");
1578        runtime.begin_frame(&world);
1579
1580        let input = no_input();
1581
1582        // First frame: advances the persistent entity's counter to 1.
1583        assert!(runtime.update_entity(&mut world, persistent_entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1584
1585        // Trigger the scene change. The trigger entity itself is non-persistent, so it (and the
1586        // transient entity) should be torn down along with the rest of the outgoing scene.
1587        assert!(runtime.update_entity(&mut world, trigger_entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1588
1589        assert!(!world.is_alive(transient_entity), "non-persistent entities should be despawned by the scene change");
1590        assert!(!world.is_alive(trigger_entity), "the entity that triggered the change is itself non-persistent");
1591        assert!(world.is_alive(persistent_entity), "a persistent entity should survive the scene change");
1592
1593        // The new scene's entity should already be renamed by its own `on_start` (drained
1594        // immediately, same as above) by the time `ChangeScene`'s handling returns.
1595        let new_entity = world
1596            .iter_entities()
1597            .find(|&e| world.names.get(e).is_some_and(|n| n.0 == "Started"))
1598            .expect("the new scene's entity should be spawned and have run its on_start");
1599        assert_ne!(new_entity, persistent_entity);
1600
1601        // Second frame, post-change: the persistent entity's script keeps running from where it
1602        // left off rather than being restarted (which would reset `counter` back to 0/1).
1603        assert!(runtime.update_entity(&mut world, persistent_entity, 0.1, &input, &no_mouse(), (0.0, 0.0), None).is_empty());
1604        assert_eq!(
1605            world.transforms.get(persistent_entity).unwrap().position.x,
1606            2.0,
1607            "persistent entity's script state should continue across the scene change, not reset"
1608        );
1609        assert_eq!(
1610            world.names.get(new_entity).unwrap().0,
1611            "Started",
1612            "the new scene's entity should have run its on_start"
1613        );
1614    }
1615}