Skip to main content

gizmo_scripting/
engine.rs

1use gizmo_core::input::Input;
2use gizmo_core::World;
3use mlua::prelude::*;
4use mlua::RegistryKey;
5use std::collections::BTreeMap;
6use std::sync::Arc;
7use tracing::{debug, error, info, trace, warn};
8
9
10/// Wakes the body a script just wrote a velocity to.
11///
12/// Required, not defensive. `PhysicsWorld::sync_bodies` **drops** a velocity written to a sleeping
13/// dynamic body, because storing one makes a stale impulse: nothing reads it while the body
14/// sleeps, and whatever wakes the body later applies it in full. `RigidBody::wake_up`'s own doc
15/// states the contract — change a velocity, wake the body — and these four commands were the
16/// scripting half of the engine ignoring it. A script that pushed a settled crate saw nothing
17/// happen, and then saw the crate leap when something unrelated disturbed the stack.
18///
19/// Called after the `Velocity` borrow is released, because this takes its own on `RigidBody`.
20fn wake_after_velocity_write(world: &mut World, id: u32) {
21    let mut rbs = world.borrow_mut::<gizmo_physics_rigid::components::RigidBody>();
22    if let Some(mut rb) = rbs.get_mut(id) {
23        rb.wake_up();
24    }
25}
26
27use crate::api_ai;
28use crate::api_audio;
29use crate::api_entity;
30use crate::api_fighter;
31use crate::api_input;
32use crate::api_physics;
33use crate::api_scene;
34use crate::api_time;
35use crate::api_vehicle;
36use crate::commands::{CommandQueue, ScriptCommand};
37
38/// Lua Scripting Motoru — Genişletilmiş API ile oyun mantığını yönetir
39pub struct ScriptEngine {
40    lua: Lua,
41    /// Loaded scripts, keyed by path — **ordered**, and that is load-bearing rather than tidy.
42    ///
43    /// This was a `std::collections::HashMap`, whose `RandomState` is seeded per process, so the
44    /// order `update` ran scripts in changed from run to run. Two scripts pushing commands that
45    /// touch the same entity therefore resolved in a random order, and this engine's headline
46    /// contract is same-platform bit-identical replay. A `BTreeMap` costs a comparison per lookup
47    /// and makes the order a property of the scripts' paths instead of of the allocator.
48    loaded_scripts: BTreeMap<String, (String, RegistryKey)>,
49    command_queue: Arc<CommandQueue>,
50    /// Hook ticks left for the Lua call currently running; see [`ScriptEngine::arm_budget`].
51    budget: Arc<std::sync::atomic::AtomicU32>,
52    /// Ticks handed out per call. `instructions / HOOK_INSTRUCTION_STEP`.
53    budget_ticks: u32,
54    elapsed_time: f32,
55    /// Log messages emitted from Lua (`print`), stored as `(level, message)` pairs.
56    pub log_queue: Arc<std::sync::Mutex<Vec<(String, String)>>>, // (Level, Message)
57}
58
59// `Send` is NOT hand-written: mlua is built with its `send` feature (see this
60// crate's Cargo.toml), which makes `Lua: Send`, and every other field is already
61// `Send`. The compiler derives it — if that ever stops holding we want the build
62// to break rather than an `unsafe impl` to paper over it.
63
64// SAFETY: `Lua` is `Send` but deliberately **not** `Sync` — mlua mutates the
65// underlying `lua_State` through `&Lua`, so two threads holding `&Lua` would
66// race. `Sync` on this type therefore has exactly one precondition:
67//
68//   *** No `&self` method of `ScriptEngine` may touch `self.lua`. ***
69//
70// That precondition holds by construction. The complete set of `&self` methods
71// is `flush_commands`, `get_pending_audio_scene_commands` and `command_queue`;
72// none of them reads `self.lua` (they only drain the `Arc<CommandQueue>` and the
73// `Arc<Mutex<..>>` log queue, both of which are `Sync` on their own). Every
74// method that does reach the VM — `new`, `load_script`, `reload_script`,
75// `update`, `has_function`, `run_entity_update`, … — takes `&mut self`, so the
76// borrow checker makes concurrent VM access unrepresentable: a caller needs
77// `ResMut<ScriptEngine>`, which the scheduler treats as an exclusive write.
78//
79// `Sync` is required because `ScriptEngine` is stored as a `World` resource and
80// `World::insert_resource` demands `Send + Sync`.
81//
82// If you add a `&self` method, it must not touch `self.lua`. The regression test
83// `shared_methods_never_reach_the_lua_vm` at the bottom of this file records the
84// audited list; update it deliberately, not incidentally.
85unsafe impl Sync for ScriptEngine {}
86
87// `Lua` does not implement `Debug`, so the engine provides a manual summary that
88// omits the VM internals while still surfacing useful state.
89impl std::fmt::Debug for ScriptEngine {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.debug_struct("ScriptEngine")
92            .field("lua", &"<Lua VM>")
93            .field("loaded_scripts", &self.loaded_scripts.keys())
94            .field("elapsed_time", &self.elapsed_time)
95            .field(
96                "queued_commands",
97                &self.command_queue.len(),
98            )
99            .field(
100                "queued_logs",
101                &self.log_queue.lock().map(|q| q.len()).unwrap_or(0),
102            )
103            .finish()
104    }
105}
106
107/// One value a script exposes to the editor.
108///
109/// Three kinds, because those are the three a property inspector can edit without inventing a
110/// widget: a number, a flag, and a name. A script that needs more structure than this wants a
111/// table it manages itself, not an inspector row.
112#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
113pub enum ScriptValue {
114    Num(f64),
115    Bool(bool),
116    Text(String),
117}
118
119impl ScriptValue {
120    /// The label the inspector shows for this kind, and what a mismatched override is checked
121    /// against: an override whose kind differs from the declaration is never *coerced*, because
122    /// silently turning `true` into `1` is how a script starts misbehaving in a way nobody can
123    /// trace to the editor.
124    ///
125    /// It is not *ignored* either, and this note used to say it was. Nothing filters
126    /// [`Script::properties`] on its way to Lua — see
127    /// `every_stored_property_reaches_the_script_declared_or_not`. The editor acted on the wrong
128    /// half of that sentence: it dropped mismatched overrides from its display and showed the
129    /// declared default, while the script kept running on the stale value. The inspector now
130    /// shows every stored value and marks the odd ones instead.
131    pub fn kind(&self) -> &'static str {
132        match self {
133            Self::Num(_) => "number",
134            Self::Bool(_) => "bool",
135            Self::Text(_) => "text",
136        }
137    }
138}
139
140/// ECS Componenti: Varlığın üzerine hangi Lua script'inin takılı olduğunu tutar
141#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
142pub struct Script {
143    pub file_path: String,
144    #[serde(default, skip)]
145    pub initialized: bool, // on_init çağrıldı mı?
146    /// Per-entity overrides for the properties this script declares.
147    ///
148    /// Scripts are loaded once per PATH — two entities running the same file share one Lua
149    /// environment — so a per-entity value cannot live in that environment. It lives here and is
150    /// handed to `on_entity_update` as its third argument.
151    ///
152    /// A `BTreeMap` for the same reason the loaded-script map is one: this crate's contract is
153    /// same-platform bit-identical replay, and a `HashMap`'s iteration order is seeded per process.
154    #[serde(default)]
155    pub properties: std::collections::BTreeMap<String, ScriptValue>,
156}
157
158impl Script {
159    pub fn new(path: &str) -> Self {
160        Self {
161            file_path: path.to_string(),
162            initialized: false,
163            properties: std::collections::BTreeMap::new(),
164        }
165    }
166}
167
168/// Lua'ya geçirilecek entity verisi (geriye dönük uyumluluk için)
169#[derive(Clone, Debug, Default)]
170#[non_exhaustive]
171pub struct ScriptContext {
172    pub entity_id: u32,
173    pub dt: f32,
174    pub position: [f32; 3],
175    pub velocity: [f32; 3],
176    pub key_w: bool,
177    pub key_a: bool,
178    pub key_s: bool,
179    pub key_d: bool,
180    pub key_space: bool,
181    pub key_up: bool,
182    pub key_down: bool,
183    pub key_left: bool,
184    pub key_right: bool,
185}
186
187/// Lua'dan dönen değişiklikler (geriye dönük uyumluluk)
188#[derive(Clone, Debug, Default)]
189pub struct ScriptResult {
190    pub new_position: Option<[f32; 3]>,
191    pub new_velocity: Option<[f32; 3]>,
192}
193
194impl ScriptEngine {
195    /// Instructions between two hook firings. The hook itself is an atomic load and a compare, so
196    /// this is not about the hook's cost — it is the resolution of the budget, and 10 000
197    /// instructions is far below one frame's worth of anything sane.
198    const HOOK_INSTRUCTION_STEP: u32 = 10_000;
199
200    /// Default ceiling for a single call into Lua: `on_update` for one script, one entity hook,
201    /// or the top level of a script being loaded.
202    ///
203    /// Two million instructions is generously above what a per-frame script should ever execute
204    /// and far below "the window stopped responding". It is a runaway guard, not a performance
205    /// budget: a script that trips it has a bug, and the alternative to tripping it was hanging
206    /// the process, because `while true do end` in a Lua VM the host has no timeout on is
207    /// unrecoverable — no signal, no watchdog, and the frame never ends.
208    pub const DEFAULT_INSTRUCTION_BUDGET: u32 = 2_000_000;
209
210    /// Default ceiling on the VM's heap. Reached, an allocation fails as a catchable Lua error
211    /// instead of the process growing until the OOM killer decides which program dies — which on
212    /// a machine running an editor and a game is not necessarily this one.
213    pub const DEFAULT_MEMORY_LIMIT: usize = 64 * 1024 * 1024;
214
215    pub fn new() -> Result<Self, LuaError> {
216        let lua = Lua::new();
217        let command_queue = Arc::new(CommandQueue::new());
218        let log_queue = Arc::new(std::sync::Mutex::new(Vec::new()));
219
220        // === SANDBOX: Tehlikeli modülleri kapat ===
221        lua.globals().set("os", LuaNil)?;
222        lua.globals().set("io", LuaNil)?;
223        lua.globals().set("loadfile", LuaNil)?;
224        lua.globals().set("dofile", LuaNil)?;
225        lua.globals().set("require", LuaNil)?;
226        lua.globals().set("package", LuaNil)?;
227        lua.globals().set("debug", LuaNil)?;
228        lua.globals().set("loadstring", LuaNil)?;
229        lua.globals().set("load", LuaNil)?;
230
231        // === RUNAWAY GUARD: instruction budget + memory ceiling ===
232        // Without these a script is unbounded in both time and space, and the host has no way to
233        // take control back: `while true do end` never yields, mlua's `call` never returns, and
234        // the frame — the window, the editor, the game — is simply over. The budget is armed per
235        // call (see `arm_budget`), so one runaway script loses its own frame and the scripts
236        // ordered after it still run.
237        let budget = Arc::new(std::sync::atomic::AtomicU32::new(0));
238        let hook_budget = budget.clone();
239        lua.set_hook(
240            mlua::HookTriggers::new().every_nth_instruction(Self::HOOK_INSTRUCTION_STEP),
241            move |_lua, _debug| {
242                // Not `fetch_sub` alone: at zero that wraps to `u32::MAX` and the guard silently
243                // stops guarding. Load-then-store is safe here because the VM is single-threaded
244                // by construction — every method that reaches it takes `&mut self`.
245                let left = hook_budget.load(std::sync::atomic::Ordering::Relaxed);
246                if left == 0 {
247                    return Err(LuaError::RuntimeError(
248                        "script exceeded its instruction budget for this call (infinite loop?)"
249                            .to_string(),
250                    ));
251                }
252                hook_budget.store(left - 1, std::sync::atomic::Ordering::Relaxed);
253                Ok(())
254            },
255        );
256        lua.set_memory_limit(Self::DEFAULT_MEMORY_LIMIT)?;
257
258        // === TEMEL PRINT FONKSİYONU ===
259        let lq_clone1 = log_queue.clone();
260        lua.globals().set(
261            "print_engine",
262            lua.create_function(move |_, msg: String| {
263                if let Ok(mut q) = lq_clone1.lock() {
264                    q.push(("info".to_string(), msg));
265                }
266                Ok(())
267            })?,
268        )?;
269
270        // Orijinal print'i de engine çıktısına yönlendir
271        let lq_clone2 = log_queue.clone();
272        lua.globals().set(
273            "print",
274            lua.create_function(move |_, values: LuaMultiValue| {
275                let parts: Vec<String> = values
276                    .iter()
277                    .map(|v| {
278                        if let mlua::Value::String(s) = v {
279                            s.to_str().unwrap_or("").to_string()
280                        } else if let mlua::Value::Number(n) = v {
281                            n.to_string()
282                        } else if let mlua::Value::Integer(i) = v {
283                            i.to_string()
284                        } else if let mlua::Value::Boolean(b) = v {
285                            b.to_string()
286                        } else {
287                            format!("{:?}", v)
288                        }
289                    })
290                    .collect();
291                if let Ok(mut q) = lq_clone2.lock() {
292                    q.push(("info".to_string(), parts.join("\t")));
293                }
294                Ok(())
295            })?,
296        )?;
297
298        // === VEC3 YARDIMCI FONKSİYONLARI ===
299        lua.load(
300            r#"
301            function vec3(x, y, z)
302                return { x = x or 0, y = y or 0, z = z or 0 }
303            end
304            
305            function vec3_add(a, b)
306                return vec3(a.x + b.x, a.y + b.y, a.z + b.z)
307            end
308            
309            function vec3_sub(a, b)
310                return vec3(a.x - b.x, a.y - b.y, a.z - b.z)
311            end
312            
313            function vec3_scale(v, s)
314                return vec3(v.x * s, v.y * s, v.z * s)
315            end
316            
317            function vec3_length(v)
318                return math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
319            end
320            
321            function vec3_normalize(v)
322                local len = vec3_length(v)
323                if len > 0.0001 then
324                    return vec3(v.x / len, v.y / len, v.z / len)
325                end
326                return vec3(0, 0, 0)
327            end
328            
329            function vec3_dot(a, b)
330                return a.x * b.x + a.y * b.y + a.z * b.z
331            end
332            
333            function vec3_cross(a, b)
334                return vec3(
335                    a.y * b.z - a.z * b.y,
336                    a.z * b.x - a.x * b.z,
337                    a.x * b.y - a.y * b.x
338                )
339            end
340            
341            function vec3_lerp(a, b, t)
342                return vec3(
343                    a.x + (b.x - a.x) * t,
344                    a.y + (b.y - a.y) * t,
345                    a.z + (b.z - a.z) * t
346                )
347            end
348            
349            function vec3_distance(a, b)
350                return vec3_length(vec3_sub(a, b))
351            end
352            
353            -- Clamp utility
354            function clamp(value, min, max)
355                return math.max(min, math.min(max, value))
356            end
357            
358            -- Lerp utility
359            function lerp(a, b, t)
360                return a + (b - a) * t
361            end
362        "#,
363        )
364        .exec()?;
365
366        // === API MODÜLLERİNİ KAYDET ===
367        api_entity::register_entity_api(&lua, command_queue.clone())?;
368        api_fighter::register_fighter_api(&lua, command_queue.clone())?;
369        api_input::register_input_api(&lua)?;
370        api_physics::register_physics_api(&lua, command_queue.clone())?;
371        api_scene::register_scene_api(&lua, command_queue.clone())?;
372        api_audio::register_audio_api(&lua, command_queue.clone())?;
373        api_time::register_time_api(&lua)?;
374        api_vehicle::register_vehicle_api(&lua, command_queue.clone())?;
375        api_ai::register_ai_api(&lua, command_queue.clone())?;
376
377        info!("[Scripting] ScriptEngine başlatıldı — Lua 5.4 sandbox aktif, API modülleri kayıtlı");
378        Ok(Self {
379            lua,
380            loaded_scripts: BTreeMap::new(),
381            command_queue,
382            budget,
383            budget_ticks: Self::DEFAULT_INSTRUCTION_BUDGET / Self::HOOK_INSTRUCTION_STEP,
384            elapsed_time: 0.0,
385            log_queue,
386        })
387    }
388
389    /// Hand the next call into Lua a fresh instruction budget.
390    ///
391    /// Per CALL, not per frame: `update` runs every loaded script, and a budget shared across them
392    /// would let the first script to misbehave spend everyone's — which is the same failure the
393    /// error-isolation fix removed from this loop, in a different currency.
394    fn arm_budget(&self) {
395        self.budget
396            .store(self.budget_ticks, std::sync::atomic::Ordering::Relaxed);
397    }
398
399    /// Change the per-call instruction ceiling. Rounded down to a multiple of the hook step, and
400    /// never to zero — a budget of zero would fail every script on its first hook.
401    pub fn set_instruction_budget(&mut self, instructions: u32) {
402        self.budget_ticks = (instructions / Self::HOOK_INSTRUCTION_STEP).max(1);
403    }
404
405    /// Change the VM's heap ceiling in bytes. Returns the previous limit.
406    pub fn set_memory_limit(&mut self, bytes: usize) -> Result<usize, LuaError> {
407        self.lua.set_memory_limit(bytes)
408    }
409
410    #[tracing::instrument(skip_all, name = "script_load", fields(path = %path))]
411    pub fn load_script(&mut self, path: &str) -> Result<(), String> {
412        let content = std::fs::read_to_string(path).map_err(|e| {
413            error!(path, error = %e, "[Scripting] Script dosyası okunamadı");
414            format!("Script okunamadı {}: {}", path, e)
415        })?;
416        let byte_len = content.len();
417
418        let env = self.lua.create_table().map_err(|e| e.to_string())?;
419
420        // Link to _G via metatable: reads fall through to the shared globals (that is how a script
421        // sees `entity`, `input`, `print`), writes land on the script's own table.
422        let meta = self.lua.create_table().map_err(|e| e.to_string())?;
423        meta.set("__index", self.lua.globals())
424            .map_err(|e| e.to_string())?;
425        env.set_metatable(Some(meta));
426
427        // `_G` inside a script means the SCRIPT's table, not the engine's globals.
428        //
429        // Without this the isolation was one-way and easy to step around by accident: an implicit
430        // `FOO = 1` stayed local, but the very next thing a Lua author reaches for — `_G.FOO = 1`,
431        // which every tutorial spells as "the explicit way to make a global" — wrote straight into
432        // the shared table, where the next script read it. Measured before the fix: script A set
433        // `_G.LEAK` and script B read it back. Two scripts sharing a mutable namespace by accident
434        // is a race with the load order, and the load order is alphabetical.
435        //
436        // Pointing `_G` at the env keeps the idiom meaning what the author expects — a global for
437        // this script — while `__index` still exposes the engine API for reading.
438        env.set("_G", env.clone()).map_err(|e| e.to_string())?;
439
440        // Script'i İzole env içinde çalıştır
441        self.arm_budget();
442        self.lua
443            .load(&content)
444            .set_environment(env.clone())
445            .exec()
446            .map_err(|e| {
447                error!(path, bytes = byte_len, error = %e, "[Scripting] Lua derleme/çalıştırma hatası");
448                format!("Lua hata {}: {}", path, e)
449            })?;
450
451        let key = self
452            .lua
453            .create_registry_value(env)
454            .map_err(|e| e.to_string())?;
455
456        // Replace existing key if it exists to free old memory
457        if let Some((_, old_key)) = self.loaded_scripts.insert(path.to_string(), (content, key)) {
458            debug!(path, "[Scripting] Var olan script değiştirildi (hot-reload), eski sürüm boşaltılıyor");
459            // Eskiden `let _ =` ile sessizce yutuluyordu; başarısızlık Lua registry
460            // belleğini sızdırır. Davranış aynı (yine yok say) ama artık en azından loglanır.
461            if let Err(e) = self.lua.remove_registry_value(old_key) {
462                warn!(path, error = %e, "[Scripting] Eski script registry değeri boşaltılamadı (olası Lua bellek sızıntısı)");
463            }
464        }
465
466        info!(path, bytes = byte_len, "🔧 [Scripting] Script yüklendi ve izole edildi");
467        Ok(())
468    }
469
470    /// Her frame çağrılan güncelleme — World verilerini Lua'ya aktarır, scriptleri çalıştırır
471    #[tracing::instrument(skip_all, name = "script_update")]
472    pub fn update(&mut self, world: &World, input: &Input, dt: f32) -> Result<(), String> {
473        self.elapsed_time += dt;
474
475        // 1. World verilerini Lua'ya aktar (read snapshot)
476        api_entity::update_entity_read_api(&self.lua, world)
477            .map_err(|e| format!("Entity API güncelleme hatası: {}", e))?;
478        api_fighter::update_fighter_read_api(&self.lua, world)
479            .map_err(|e| format!("Fighter API güncelleme hatası: {}", e))?;
480        api_input::update_input_api(&self.lua, input)
481            .map_err(|e| format!("Input API güncelleme hatası: {}", e))?;
482        api_scene::update_scene_api(&self.lua, world)
483            .map_err(|e| format!("Scene API güncelleme hatası: {}", e))?;
484        api_time::update_time_api(&self.lua, dt, self.elapsed_time, 1.0 / dt.max(0.0001))
485            .map_err(|e| format!("Time API güncelleme hatası: {}", e))?;
486        api_physics::update_physics_api(&self.lua, world)
487            .map_err(|e| format!("Physics API güncelleme hatası: {}", e))?;
488
489        // 2. on_update callback'ini çağır — her yüklü script'in KENDİ env'inden.
490        //    Script'ler izole bir env içinde çalıştırıldığından (load_script), top-level
491        //    `function on_update` globals'a DEĞİL o env'e yazılır; globals'tan okumak
492        //    (eski kod) onu ASLA bulamaz → hook sessizce hiç çalışmazdı.
493        let ctx_table = self.lua.create_table().map_err(|e| e.to_string())?;
494        ctx_table.set("dt", dt).map_err(|e| e.to_string())?;
495        ctx_table
496            .set("elapsed", self.elapsed_time)
497            .map_err(|e| e.to_string())?;
498
499        // **One script's failure must not cancel the others.** This loop used to `?` on the first
500        // runtime error, so a single throwing script silently stopped every script ordered after it
501        // for that frame — and with the map now ordered by path, "after it" is a stable and
502        // therefore reliably silent set. Errors are collected and reported together instead; a
503        // broken script loses its own frame and nobody else's.
504        // Wrapped in the call-time query scope: for the length of this loop — and only for it —
505        // the physics API carries functions that hold `&World` and can answer a question the
506        // engine could not have precomputed. See `api_physics::with_call_time_queries`.
507        let lua = &self.lua;
508        let scripts = &self.loaded_scripts;
509        let budget = &self.budget;
510        let budget_ticks = self.budget_ticks;
511        let mut failures = Vec::new();
512        api_physics::with_call_time_queries(lua, world, || {
513            for (path, (_, key)) in scripts {
514                let env: mlua::Table = match lua.registry_value(key) {
515                    Ok(env) => env,
516                    Err(e) => {
517                        failures.push(format!("{path}: env okunamadı: {e}"));
518                        continue;
519                    }
520                };
521                if let Ok(func) = env.get::<_, LuaFunction>("on_update") {
522                    budget.store(budget_ticks, std::sync::atomic::Ordering::Relaxed);
523                    if let Err(e) = func.call::<_, ()>(ctx_table.clone()) {
524                        warn!(path = %path, error = %e, "[Scripting] on_update çalışma-zamanı hatası");
525                        failures.push(format!("Lua on_update hatası ({path}): {e}"));
526                    }
527                }
528            }
529            Ok(())
530        })
531        .map_err(|e| format!("script scope hatası: {e}"))?;
532
533        if failures.is_empty() {
534            Ok(())
535        } else {
536            // Every failure, not just the first: a caller that logs this sees the whole frame's
537            // damage rather than one arbitrary script's share of it.
538            Err(failures.join(" | "))
539        }
540    }
541
542    /// Per-entity script güncelleme — Script component'i olan entity'ler için izole ortamda çalıştırır
543    /// The properties a script DECLARES, read from its `properties` table.
544    ///
545    /// The convention is a plain assignment at the top of the file:
546    ///
547    /// ```lua
548    /// properties = { open_speed = 2.4, locked = false }
549    /// ```
550    ///
551    /// which lands in the script's own environment (`_G` is that environment — see `load_script`).
552    /// This is the schema and the defaults: the editor lists these names, and an entity that has
553    /// not overridden one runs with the declared value.
554    ///
555    /// Anything that is not a number, boolean or string is skipped rather than guessed at — a
556    /// nested table is a script's own business and not an inspector row.
557    pub fn declared_properties(
558        &self,
559        script_path: &str,
560    ) -> std::collections::BTreeMap<String, ScriptValue> {
561        let mut out = std::collections::BTreeMap::new();
562        let Some((_, key)) = self.loaded_scripts.get(script_path) else {
563            return out;
564        };
565        let Ok(env) = self.lua.registry_value::<mlua::Table>(key) else {
566            return out;
567        };
568        let Ok(table) = env.get::<_, mlua::Table>("properties") else {
569            return out;
570        };
571        for pair in table.pairs::<String, mlua::Value>() {
572            let Ok((name, value)) = pair else { continue };
573            let converted = match value {
574                mlua::Value::Number(n) => Some(ScriptValue::Num(n)),
575                mlua::Value::Integer(i) => Some(ScriptValue::Num(i as f64)),
576                mlua::Value::Boolean(b) => Some(ScriptValue::Bool(b)),
577                mlua::Value::String(s) => s.to_str().ok().map(|t| ScriptValue::Text(t.to_string())),
578                _ => None,
579            };
580            if let Some(v) = converted {
581                out.insert(name, v);
582            }
583        }
584        out
585    }
586
587
588    /// Reads a numeric expression out of a loaded script's environment. Test-only.
589    #[cfg(test)]
590    pub fn eval_number(&self, script_path: &str, expr: &str) -> Option<f64> {
591        let (_, key) = self.loaded_scripts.get(script_path)?;
592        let env: mlua::Table = self.lua.registry_value(key).ok()?;
593        self.lua
594            .load(format!("return {expr}"))
595            .set_environment(env)
596            .eval::<f64>()
597            .ok()
598    }
599
600    /// Runs `on_entity_update(entity_id, dt, props)` for one entity.
601    ///
602    /// `properties` are that entity's own values — the third argument exists because scripts are
603    /// loaded per PATH, so two entities running the same file share one Lua environment and cannot
604    /// each keep a value in it. Passing them is additive: a script whose `on_entity_update` takes
605    /// two parameters simply ignores the third, which is why this did not need a new hook name.
606    pub fn update_entity(
607        &mut self,
608        entity_id: u32,
609        script_path: &str,
610        dt: f32,
611        properties: &std::collections::BTreeMap<String, ScriptValue>,
612    ) -> Result<(), String> {
613        if let Some((_, key)) = self.loaded_scripts.get(script_path) {
614            let env: mlua::Table = self.lua.registry_value(key).map_err(|e| e.to_string())?;
615
616            // on_entity_update(entity_id, dt, props) çağır (varsa)
617            if let Ok(func) = env.get::<_, LuaFunction>("on_entity_update") {
618                let props = self.lua.create_table().map_err(|e| e.to_string())?;
619                for (name, value) in properties {
620                    let set = match value {
621                        ScriptValue::Num(n) => props.set(name.as_str(), *n),
622                        ScriptValue::Bool(b) => props.set(name.as_str(), *b),
623                        ScriptValue::Text(t) => props.set(name.as_str(), t.as_str()),
624                    };
625                    set.map_err(|e| e.to_string())?;
626                }
627                self.arm_budget();
628                func.call::<_, ()>((entity_id, dt, props)).map_err(|e| {
629                    warn!(entity_id, script_path, error = %e, "[Scripting] on_entity_update çalışma-zamanı hatası");
630                    format!(
631                        "Lua on_entity_update hatası (entity {} mod {}): {}",
632                        entity_id, script_path, e
633                    )
634                })?;
635            }
636        } else {
637            trace!(entity_id, script_path, "[Scripting] update_entity: script yüklü değil, atlandı");
638        }
639        Ok(())
640    }
641
642    /// Komut kuyruğundaki tüm komutları World'e uygular ve oyun mantığı için kalan komutları döndürür
643    #[tracing::instrument(skip_all, name = "script_flush_commands")]
644    pub fn flush_commands(&self, world: &mut World, dt: f32) -> Vec<ScriptCommand> {
645        let commands = self.command_queue.drain();
646        let total = commands.len();
647        let mut unhandled = Vec::new();
648
649        for cmd in commands {
650            match cmd {
651                ScriptCommand::SetPosition(id, pos) => {
652                    let mut transforms = world.borrow_mut::<gizmo_physics_core::Transform>();
653                    if let Some(mut t) = transforms.get_mut(id) {
654                        t.position = pos;
655                    } else {
656                        trace!(entity = id, "[Scripting] SetPosition: hedefte Transform yok, komut atlandı");
657                    }
658                }
659                ScriptCommand::SetRotation(id, rot) => {
660                    let mut transforms = world.borrow_mut::<gizmo_physics_core::Transform>();
661                    if let Some(mut t) = transforms.get_mut(id) {
662                        t.rotation = rot;
663                    } else {
664                        trace!(entity = id, "[Scripting] SetRotation: hedefte Transform yok, komut atlandı");
665                    }
666                }
667                ScriptCommand::SetScale(id, scale) => {
668                    let mut transforms = world.borrow_mut::<gizmo_physics_core::Transform>();
669                    if let Some(mut t) = transforms.get_mut(id) {
670                        t.scale = scale;
671                    } else {
672                        trace!(entity = id, "[Scripting] SetScale: hedefte Transform yok, komut atlandı");
673                    }
674                }
675                ScriptCommand::SetVelocity(id, vel) => {
676                    let mut written = false;
677                    {
678                        let mut velocities = world.borrow_mut::<gizmo_physics_rigid::components::Velocity>();
679                        if let Some(mut v) = velocities.get_mut(id) {
680                            v.linear = vel;
681                            written = true;
682                        } else {
683                            trace!(entity = id, "[Scripting] SetVelocity: hedefte Velocity yok, komut atlandı");
684                        }
685                    }
686                    if written {
687                        wake_after_velocity_write(world, id);
688                    }
689                }
690                ScriptCommand::SetAngularVelocity(id, ang_vel) => {
691                    let mut written = false;
692                    {
693                        let mut velocities = world.borrow_mut::<gizmo_physics_rigid::components::Velocity>();
694                        if let Some(mut v) = velocities.get_mut(id) {
695                            v.angular = ang_vel;
696                            written = true;
697                        } else {
698                            trace!(entity = id, "[Scripting] SetAngularVelocity: hedefte Velocity yok, komut atlandı");
699                        }
700                    }
701                    if written {
702                        wake_after_velocity_write(world, id);
703                    }
704                }
705                ScriptCommand::ApplyForce(id, force) => {
706                    let rbs = world.borrow::<gizmo_physics_rigid::components::RigidBody>();
707                    if let Some(rb) = rbs.get(id) {
708                        if rb.mass > 0.0 {
709                            let accel = force * (1.0 / rb.mass);
710                            drop(rbs);
711                            // RigidBody var ama Velocity yoksa sıfır hızla oluştur ki
712                            // kuvvet sessizce kaybolmasın.
713                            if world
714                                .borrow::<gizmo_physics_rigid::components::Velocity>()
715                                .get(id)
716                                .is_none()
717                            {
718                                if let Some(e) = world.entity(id) {
719                                    world.add_component(
720                                        e,
721                                        gizmo_physics_rigid::components::Velocity::new(
722                                            gizmo_math::Vec3::ZERO,
723                                        ),
724                                    );
725                                }
726                            }
727                            {
728                                let mut vels =
729                                    world.borrow_mut::<gizmo_physics_rigid::components::Velocity>();
730                                if let Some(mut v) = vels.get_mut(id) {
731                                    v.linear += accel * dt;
732                                }
733                            }
734                            wake_after_velocity_write(world, id);
735                        }
736                    } else {
737                        trace!(entity = id, "[Scripting] ApplyForce: hedefte RigidBody yok, kuvvet yok sayıldı");
738                    }
739                }
740                ScriptCommand::ApplyImpulse(id, impulse) => {
741                    let rbs = world.borrow::<gizmo_physics_rigid::components::RigidBody>();
742                    if let Some(rb) = rbs.get(id) {
743                        if rb.mass > 0.0 {
744                            let delta_v = impulse * (1.0 / rb.mass);
745                            drop(rbs);
746                            // RigidBody var ama Velocity yoksa sıfır hızla oluştur ki
747                            // impuls sessizce kaybolmasın.
748                            if world
749                                .borrow::<gizmo_physics_rigid::components::Velocity>()
750                                .get(id)
751                                .is_none()
752                            {
753                                if let Some(e) = world.entity(id) {
754                                    world.add_component(
755                                        e,
756                                        gizmo_physics_rigid::components::Velocity::new(
757                                            gizmo_math::Vec3::ZERO,
758                                        ),
759                                    );
760                                }
761                            }
762                            {
763                                let mut vels =
764                                    world.borrow_mut::<gizmo_physics_rigid::components::Velocity>();
765                                if let Some(mut v) = vels.get_mut(id) {
766                                    v.linear += delta_v;
767                                }
768                            }
769                            wake_after_velocity_write(world, id);
770                        }
771                    } else {
772                        trace!(entity = id, "[Scripting] ApplyImpulse: hedefte RigidBody yok, impuls yok sayıldı");
773                    }
774                }
775                ScriptCommand::AddRigidBody {
776                    id,
777                    mass,
778                    use_gravity,
779                } => {
780                    let entity = world.entity(id);
781                    if let Some(e) = entity {
782                        let rb = gizmo_physics_rigid::components::RigidBody::new(mass, use_gravity);
783                        world.add_component(e, rb);
784                        // Make sure velocity exists so it can move
785                        if world
786                            .borrow::<gizmo_physics_rigid::components::Velocity>()
787                            .get(id)
788                            .is_none()
789                        {
790                            world.add_component(
791                                e,
792                                gizmo_physics_rigid::components::Velocity::new(gizmo_math::Vec3::ZERO),
793                            );
794                        }
795                    } else {
796                        trace!(entity = id, "[Scripting] AddRigidBody: entity bulunamadı, komut atlandı");
797                    }
798                }
799                ScriptCommand::AddBoxCollider { id, hx, hy, hz } => {
800                    let entity = world.entity(id);
801                    if let Some(e) = entity {
802                        let col =
803                            gizmo_physics_core::Collider::aabb(gizmo_math::Vec3::new(hx, hy, hz));
804                        world.add_component(e, col);
805                    } else {
806                        trace!(entity = id, "[Scripting] AddBoxCollider: entity bulunamadı, komut atlandı");
807                    }
808                }
809                ScriptCommand::AddSphereCollider { id, radius } => {
810                    let entity = world.entity(id);
811                    if let Some(e) = entity {
812                        let col = gizmo_physics_core::Collider::sphere(radius);
813                        world.add_component(e, col);
814                    } else {
815                        trace!(entity = id, "[Scripting] AddSphereCollider: entity bulunamadı, komut atlandı");
816                    }
817                }
818
819                // The three vehicle commands used to be matched here with empty bodies: Lua could
820                // call them, they queued, and they vanished without a word. Applying them properly
821                // needs `VehicleController`, which lives in `gizmo-physics-dynamics` and is not a
822                // dependency of this crate — adding one to reach three commands is the wrong trade,
823                // and the host that flushes these does have it. So they fall through to `unhandled`
824                // like everything else this crate cannot apply itself, and the host is told.
825
826                ScriptCommand::SpawnEntity { name, position } => {
827                    let entity = world.spawn();
828                    world.add_component(entity, gizmo_core::EntityName::new(&name));
829                    world
830                        .add_component(entity, gizmo_physics_core::Transform::new(position));
831                    let msg = format!(
832                        "Entity spawn: '{}' at ({:.1}, {:.1}, {:.1})",
833                        name, position.x, position.y, position.z
834                    );
835                    if let Ok(mut q) = self.log_queue.lock() {
836                        q.push(("info".to_string(), msg));
837                    }
838                }
839                ScriptCommand::SpawnPrefab {
840                    name,
841                    prefab_type,
842                    position,
843                } => {
844                    let entity = world.spawn();
845                    world.add_component(entity, gizmo_core::EntityName::new(&name));
846                    world
847                        .add_component(entity, gizmo_physics_core::Transform::new(position));
848                    world.add_component(entity, gizmo_core::PrefabRequest(prefab_type.clone()));
849                }
850                ScriptCommand::DestroyEntity(id) => {
851                    world.despawn_by_id(id);
852                    if let Ok(mut q) = self.log_queue.lock() {
853                        q.push(("info".to_string(), format!("Entity destroyed: {}", id)));
854                    }
855                }
856ScriptCommand::SetEntityName(id, name) => {
857                    let mut names = world.borrow_mut::<gizmo_core::EntityName>();
858                    if let Some(mut n) = names.get_mut(id) {
859                        n.0 = name;
860                    } else {
861                        trace!(entity = id, "[Scripting] SetEntityName: hedefte EntityName yok, komut atlandı");
862                    }
863                }
864ScriptCommand::PlayAnimation { id, name, blend, loop_anim } => {
865                    let mut players = world.borrow_mut::<gizmo_animation::skeletal::AnimationPlayer>();
866                    if let Some(mut player) = players.get_mut(id) {
867                        player.play_animation_by_name(&name, blend, loop_anim);
868                    } else {
869                        trace!(entity = id, anim = %name, "[Scripting] PlayAnimation: hedefte AnimationPlayer yok, komut atlandı");
870                    }
871                }
872                ScriptCommand::SetAnimationSpeed(id, speed) => {
873                    let mut players = world.borrow_mut::<gizmo_animation::skeletal::AnimationPlayer>();
874                    if let Some(mut player) = players.get_mut(id) {
875                        player.speed = speed;
876                    } else {
877                        trace!(entity = id, "[Scripting] SetAnimationSpeed: hedefte AnimationPlayer yok, komut atlandı");
878                    }
879                }
880                ScriptCommand::AddNavAgent(id) => {
881                    let entity = world.entity(id);
882                    if let Some(e) = entity {
883                        world.add_component(e, gizmo_ai::components::NavAgent::default());
884                    } else {
885                        trace!(entity = id, "[Scripting] AddNavAgent: entity bulunamadı, komut atlandı");
886                    }
887                }
888                ScriptCommand::SetAiTarget(id, target) => {
889                    let mut agents = world.borrow_mut::<gizmo_ai::components::NavAgent>();
890                    if let Some(mut agent) = agents.get_mut(id) {
891                        agent.set_target(target);
892                    } else {
893                        trace!(entity = id, "[Scripting] SetAiTarget: hedefte NavAgent yok, komut atlandı");
894                    }
895                }
896                ScriptCommand::ClearAiTarget(id) => {
897                    let mut agents = world.borrow_mut::<gizmo_ai::components::NavAgent>();
898                    if let Some(mut agent) = agents.get_mut(id) {
899                        // Must clear the TARGET, not just the path — clearing only the path
900                        // leaves target set, so ai_navigation_system recomputes and keeps going.
901                        agent.clear_target();
902                    } else {
903                        trace!(entity = id, "[Scripting] ClearAiTarget: hedefte NavAgent yok, komut atlandı");
904                    }
905                }
906                ScriptCommand::SetFighterMove { id, name, startup, active, recovery, damage } => {
907                    let mut fighters = world.borrow_mut::<gizmo_physics_core::components::FighterController>();
908                    if let Some(mut fighter) = fighters.get_mut(id) {
909                        let mut frame_data =
910                            gizmo_physics_core::components::fighter::FrameData::default();
911                        frame_data.startup = startup;
912                        frame_data.active = active;
913                        frame_data.recovery = recovery;
914                        frame_data.damage = damage;
915                        let mut combat_move =
916                            gizmo_physics_core::components::fighter::CombatMove::default();
917                        combat_move.name = name;
918                        combat_move.frame_data = frame_data;
919                        fighter.active_move = Some(combat_move);
920                        fighter.current_move_frame = 0;
921                    } else {
922                        trace!(entity = id, "[Scripting] SetFighterMove: hedefte FighterController yok, komut atlandı");
923                    }
924                }
925                ScriptCommand::ApplyHitstop(id, frames) => {
926                    let mut fighters = world.borrow_mut::<gizmo_physics_core::components::FighterController>();
927                    if let Some(mut fighter) = fighters.get_mut(id) {
928                        fighter.apply_hitstop(frames);
929                    } else {
930                        trace!(entity = id, frames, "[Scripting] ApplyHitstop: hedefte FighterController yok, komut atlandı");
931                    }
932                }
933                ScriptCommand::ApplyHitstun(id, frames) => {
934                    let mut fighters = world.borrow_mut::<gizmo_physics_core::components::FighterController>();
935                    if let Some(mut fighter) = fighters.get_mut(id) {
936                        fighter.apply_hitstun(frames);
937                    } else {
938                        trace!(entity = id, frames, "[Scripting] ApplyHitstun: hedefte FighterController yok, komut atlandı");
939                    }
940                }
941                // The scene, dialogue, race and camera commands used to be matched here by an
942                // arm whose body was empty and whose comment said they would "already appear in
943                // unhandled". They could not: this arm consumed them, so the `other` catch-all
944                // below never saw them and the host was never told. Deleting the arm is the whole
945                // fix — they now fall through and are returned, which is what the comment claimed.
946                other => {
947                    unhandled.push(other);
948                }
949            }
950        }
951
952        if total > 0 {
953            trace!(
954                total,
955                unhandled = unhandled.len(),
956                "[Scripting] script komut kuyruğu boşaltıldı"
957            );
958        }
959        unhandled
960    }
961
962    /// Runtime'da bekleyen ses/sahne komutlarını döndürür (demo tarafında ele alınır)
963    pub fn get_pending_audio_scene_commands(&self) -> Vec<ScriptCommand> {
964        // Flush zaten çağrıldıysa bu boş dönecek
965        // Alternatif: flush'tan önce çağrılmalı
966        Vec::new()
967    }
968
969    /// Script'in hot-reload edilip edilmeyeceğini kontrol eder
970    pub fn reload_if_changed(&mut self, path: &str) -> Result<bool, String> {
971        let current =
972            std::fs::read_to_string(path).map_err(|e| format!("Script okunamadı: {}", e))?;
973
974        if let Some((cached_code, _)) = self.loaded_scripts.get(path) {
975            if *cached_code == current {
976                return Ok(false);
977            }
978        }
979
980        self.load_script(path)?;
981        Ok(true)
982    }
983
984    /// Belirli bir isimdeki Lua fonksiyonunun var olup olmadığını kontrol eder
985    ///
986    /// Takes `&mut self` even though it only reads: `registry_value` mutates the
987    /// underlying `lua_State`, and the `unsafe impl Sync` above is only sound
988    /// while no `&self` method reaches the VM.
989    pub fn has_function(&mut self, path: &str, name: &str) -> bool {
990        if let Some((_, key)) = self.loaded_scripts.get(path) {
991            if let Ok(env) = self.lua.registry_value::<mlua::Table>(key) {
992                return env.get::<_, LuaFunction>(name).is_ok();
993            }
994        }
995        false
996    }
997
998    /// Belirli bir isimdeki Lua fonksiyonunu çağırır (per-entity scriptler için)
999    ///
1000    /// Takes `&mut self`: calling into the VM mutates the `lua_State`, and the
1001    /// `unsafe impl Sync` above is only sound while no `&self` method does that.
1002    pub fn run_entity_update(
1003        &mut self,
1004        path: &str,
1005        func_name: &str,
1006        ctx: &ScriptContext,
1007    ) -> Result<ScriptResult, String> {
1008        let env: mlua::Table = if let Some((_, key)) = self.loaded_scripts.get(path) {
1009            self.lua.registry_value(key).map_err(|e| e.to_string())?
1010        } else {
1011            return Err(format!("Script not loaded: {}", path));
1012        };
1013
1014        let func: LuaFunction = match env.get(func_name) {
1015            Ok(f) => f,
1016            Err(e) => {
1017                trace!(path, func_name, error = %e, "[Scripting] run_entity_update: fonksiyon alınamadı, varsayılan sonuç");
1018                return Ok(ScriptResult::default());
1019            }
1020        };
1021
1022        let ctx_table = self.lua.create_table().map_err(|e| e.to_string())?;
1023        ctx_table
1024            .set("entity_id", ctx.entity_id)
1025            .map_err(|e| e.to_string())?;
1026        ctx_table.set("dt", ctx.dt).map_err(|e| e.to_string())?;
1027        ctx_table
1028            .set("elapsed", self.elapsed_time)
1029            .map_err(|e| e.to_string())?;
1030
1031        let pos = self.lua.create_table().map_err(|e| e.to_string())?;
1032        pos.set("x", ctx.position[0]).map_err(|e| e.to_string())?;
1033        pos.set("y", ctx.position[1]).map_err(|e| e.to_string())?;
1034        pos.set("z", ctx.position[2]).map_err(|e| e.to_string())?;
1035        ctx_table.set("position", pos).map_err(|e| e.to_string())?;
1036
1037        let vel = self.lua.create_table().map_err(|e| e.to_string())?;
1038        vel.set("x", ctx.velocity[0]).map_err(|e| e.to_string())?;
1039        vel.set("y", ctx.velocity[1]).map_err(|e| e.to_string())?;
1040        vel.set("z", ctx.velocity[2]).map_err(|e| e.to_string())?;
1041        ctx_table.set("velocity", vel).map_err(|e| e.to_string())?;
1042
1043        let input = self.lua.create_table().map_err(|e| e.to_string())?;
1044        input.set("w", ctx.key_w).map_err(|e| e.to_string())?;
1045        input.set("a", ctx.key_a).map_err(|e| e.to_string())?;
1046        input.set("s", ctx.key_s).map_err(|e| e.to_string())?;
1047        input.set("d", ctx.key_d).map_err(|e| e.to_string())?;
1048        input
1049            .set("space", ctx.key_space)
1050            .map_err(|e| e.to_string())?;
1051        input.set("up", ctx.key_up).map_err(|e| e.to_string())?;
1052        input.set("down", ctx.key_down).map_err(|e| e.to_string())?;
1053        input.set("left", ctx.key_left).map_err(|e| e.to_string())?;
1054        input
1055            .set("right", ctx.key_right)
1056            .map_err(|e| e.to_string())?;
1057        ctx_table.set("input", input).map_err(|e| e.to_string())?;
1058
1059        self.arm_budget();
1060        let result_table: LuaTable = func.call(ctx_table).map_err(|e| {
1061            warn!(path, func_name, error = %e, "[Scripting] run_entity_update: Lua çalışma-zamanı hatası");
1062            format!("Lua runtime: {}", e)
1063        })?;
1064
1065        let mut result = ScriptResult::default();
1066
1067        if let Ok(pos) = result_table.get::<_, LuaTable>("position") {
1068            let x: f32 = pos.get("x").unwrap_or(0.0);
1069            let y: f32 = pos.get("y").unwrap_or(0.0);
1070            let z: f32 = pos.get("z").unwrap_or(0.0);
1071            result.new_position = Some([x, y, z]);
1072        }
1073
1074        if let Ok(vel) = result_table.get::<_, LuaTable>("velocity") {
1075            let x: f32 = vel.get("x").unwrap_or(0.0);
1076            let y: f32 = vel.get("y").unwrap_or(0.0);
1077            let z: f32 = vel.get("z").unwrap_or(0.0);
1078            result.new_velocity = Some([x, y, z]);
1079        }
1080
1081        Ok(result)
1082    }
1083
1084    /// Komut kuyruğuna doğrudan erişim (internals)
1085    pub fn command_queue(&self) -> &Arc<CommandQueue> {
1086        &self.command_queue
1087    }
1088}
1089
1090gizmo_core::impl_component!(Script);
1091
1092#[cfg(test)]
1093mod soundness {
1094    use super::*;
1095
1096    /// `ScriptEngine` must be `Send + Sync` — it is stored as a `World`
1097    /// resource and `insert_resource` requires both.
1098    ///
1099    /// `Send` is derived (mlua's `send` feature makes `Lua: Send`); `Sync` is
1100    /// the hand-written `unsafe impl` above.
1101    #[test]
1102    fn script_engine_is_send_and_sync() {
1103        fn assert_send_sync<T: Send + Sync>() {}
1104        assert_send_sync::<ScriptEngine>();
1105    }
1106
1107    /// Locks the precondition of the `unsafe impl Sync for ScriptEngine`:
1108    /// **no `&self` method may touch `self.lua`**, because mlua mutates the
1109    /// `lua_State` through `&Lua` and two threads sharing `&ScriptEngine` would
1110    /// race on it.
1111    ///
1112    /// The audited shared surface is exactly these three methods, none of which
1113    /// reads `self.lua`:
1114    ///   - `flush_commands`
1115    ///   - `get_pending_audio_scene_commands`
1116    ///   - `command_queue`
1117    ///
1118    /// This test calls each of them through a genuinely shared `&ScriptEngine`
1119    /// obtained from two threads at once. It cannot prove the absence of a
1120    /// future `&self` VM access on its own — but it does prove these three stay
1121    /// callable from a shared reference, so converting one of them to
1122    /// `&mut self` (the correct move if it ever needs the VM) breaks this test
1123    /// and forces the SAFETY comment to be revisited.
1124    #[test]
1125    fn shared_methods_never_reach_the_lua_vm() {
1126        let engine = ScriptEngine::new().expect("Lua VM");
1127        let shared = &engine;
1128
1129        std::thread::scope(|s| {
1130            for _ in 0..2 {
1131                s.spawn(move || {
1132                    // Every `&self` method on the audited list, exercised
1133                    // concurrently. If any of these grew a `self.lua` access,
1134                    // this is a data race that Miri/TSan would flag here.
1135                    let _ = shared.get_pending_audio_scene_commands();
1136                    let _ = shared.command_queue().len();
1137                });
1138            }
1139        });
1140
1141        // `flush_commands` needs a &mut World, so drive it on one thread — the
1142        // point is only that it is reachable through `&self`.
1143        let mut world = gizmo_core::World::new();
1144        let _ = shared.flush_commands(&mut world, 1.0 / 60.0);
1145    }
1146
1147    /// The two methods that DO reach the VM must require exclusive access, so
1148    /// the borrow checker — not a comment — prevents concurrent VM use.
1149    ///
1150    /// This is a compile-time assertion: it only builds while both take
1151    /// `&mut self`. Reverting either to `&self` fails to compile here.
1152    #[test]
1153    fn vm_touching_methods_require_exclusive_access() {
1154        fn _needs_mut(e: &mut ScriptEngine) {
1155            let _ = e.has_function("nope.lua", "on_update");
1156        }
1157        fn _needs_mut_2(e: &mut ScriptEngine, ctx: &ScriptContext) {
1158            let _ = e.run_entity_update("nope.lua", "on_update", ctx);
1159        }
1160    }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165
1166    /// One script's globals must not be another's, including the explicit spelling.
1167    ///
1168    /// Each script already ran in its own environment, so an implicit `FOO = 1` stayed local. But
1169    /// `_G` resolved to the ENGINE's globals through the environment's `__index`, so `_G.FOO = 1`
1170    /// — the spelling every Lua tutorial gives for "make this global" — wrote into the shared
1171    /// table and the next script read it back. Measured, not theorised: script A set `_G.LEAK` and
1172    /// script B saw `from-a`. Two scripts sharing a mutable namespace by accident is a race with
1173    /// the load order, and the load order is alphabetical.
1174    #[test]
1175    fn a_script_cannot_reach_another_through_g() {
1176        let dir = std::env::temp_dir().join(format!("gizmo_sandbox_{}", std::process::id()));
1177        std::fs::create_dir_all(&dir).unwrap();
1178        let a = dir.join("a_writer.lua");
1179        let b = dir.join("b_reader.lua");
1180        std::fs::write(&a, "function on_update(c)\n  _G.LEAK = 'from-a'\n  IMPLICIT = 'also-a'\nend\n")
1181            .unwrap();
1182        std::fs::write(
1183            &b,
1184            "function on_update(c)\n  print('LEAK=' .. tostring(_G.LEAK))\n  print('IMPLICIT=' .. tostring(IMPLICIT))\nend\n",
1185        )
1186        .unwrap();
1187
1188        let mut engine = ScriptEngine::new().unwrap();
1189        engine.load_script(a.to_str().unwrap()).unwrap();
1190        engine.load_script(b.to_str().unwrap()).unwrap();
1191        engine.update(&World::new(), &Input::default(), 0.016).unwrap();
1192
1193        let log = engine.log_queue.lock().unwrap().clone();
1194        let said = |needle: &str| log.iter().any(|(_, m)| m.contains(needle));
1195        assert!(said("LEAK=nil"), "`_G.X` from one script reached another: {log:?}");
1196        assert!(said("IMPLICIT=nil"), "an implicit global reached another script: {log:?}");
1197        std::fs::remove_dir_all(&dir).ok();
1198    }
1199
1200    /// …and the containment must not have cost the script its API. `_G` is the script's own table
1201    /// now, but reads still fall through to the engine's globals, which is what makes `print`,
1202    /// `input` and the rest visible at all.
1203    #[test]
1204    fn a_script_still_reaches_the_engine_api_and_its_own_globals() {
1205        let dir = std::env::temp_dir().join(format!("gizmo_sandbox2_{}", std::process::id()));
1206        std::fs::create_dir_all(&dir).unwrap();
1207        let path = dir.join("s.lua");
1208        std::fs::write(
1209            &path,
1210            "function on_update(c)\n  _G.MINE = 5\n  print('mine=' .. tostring(MINE))\n  print('api=' .. tostring(_G.input ~= nil and _G.print ~= nil))\n  print('std=' .. tostring(string.rep('x', 2)))\nend\n",
1211        )
1212        .unwrap();
1213
1214        let mut engine = ScriptEngine::new().unwrap();
1215        engine.load_script(path.to_str().unwrap()).unwrap();
1216        engine.update(&World::new(), &Input::default(), 0.016).unwrap();
1217
1218        let log = engine.log_queue.lock().unwrap().clone();
1219        let said = |needle: &str| log.iter().any(|(_, m)| m.contains(needle));
1220        assert!(said("mine=5"), "a script's own `_G` write must be visible to itself: {log:?}");
1221        assert!(said("api=true"), "the engine API must still resolve through `_G`: {log:?}");
1222        assert!(said("std=xx"), "the Lua standard library must still resolve: {log:?}");
1223        std::fs::remove_dir_all(&dir).ok();
1224    }
1225
1226    /// A script cannot rewrite the engine API out from under the other scripts.
1227    ///
1228    /// `_G` isolation made a script's *globals* its own. It did not make the API tables its own,
1229    /// because `input.is_pressed = f` is not a global write — it is a field write on an object
1230    /// every script holds a reference to. Measured before the fix: script A replaced
1231    /// `input.is_pressed`, and script B called A's version.
1232    ///
1233    /// What closes it is a proxy (see `api_table`), and specifically not a bare `__newindex`:
1234    /// that metamethod fires only for keys the table does not already have, and every key worth
1235    /// clobbering is one it has.
1236    #[test]
1237    fn a_script_cannot_rewrite_the_api_for_everyone_else() {
1238        let dir = std::env::temp_dir().join(format!("gizmo_api_ro_{}", std::process::id()));
1239        std::fs::create_dir_all(&dir).unwrap();
1240        let a = dir.join("a_vandal.lua");
1241        let b = dir.join("b_victim.lua");
1242        std::fs::write(
1243            &a,
1244            "function on_update(c)\n  input.is_pressed = function(k) return 'CLOBBERED' end\nend\n",
1245        )
1246        .unwrap();
1247        std::fs::write(
1248            &b,
1249            "function on_update(c)\n  print('sees=' .. tostring(input.is_pressed('w')))\nend\n",
1250        )
1251        .unwrap();
1252
1253        let mut engine = ScriptEngine::new().unwrap();
1254        engine.load_script(a.to_str().unwrap()).unwrap();
1255        engine.load_script(b.to_str().unwrap()).unwrap();
1256
1257        // The vandal's own frame fails — loudly, with the reason — and the victim's does not.
1258        let err = engine.update(&World::new(), &Input::default(), 0.016).unwrap_err();
1259        assert!(err.contains("read-only"), "expected a read-only refusal, got: {err}");
1260
1261        let log = engine.log_queue.lock().unwrap().clone();
1262        assert!(
1263            log.iter().any(|(_, m)| m.contains("sees=false")),
1264            "the neighbour saw a rewritten API: {log:?}"
1265        );
1266        std::fs::remove_dir_all(&dir).ok();
1267    }
1268
1269    /// A parameterised query the engine could not have precomputed, answered while the script is
1270    /// calling.
1271    ///
1272    /// This is the item the audit recorded as blocked. Its reasoning was right about
1273    /// `Lua::create_function` — with mlua's `send` feature that wants `Fn(..) + Send + 'static`,
1274    /// and `&World` is neither — and wrong about the conclusion, because `Scope::create_function`
1275    /// carries no such bound: `F: Fn(..) + 'scope`. A scoped closure may borrow the world, and the
1276    /// borrow ends when the scope does, which is the frame.
1277    ///
1278    /// "Ground height at (x, z)" is the audit's own example, and it is the right shape of example:
1279    /// there is no snapshot that answers it, because the engine does not know which (x, z) the
1280    /// script will ask about until it asks.
1281    #[test]
1282    fn a_script_can_ask_a_question_the_engine_did_not_precompute() {
1283        use gizmo_physics_rigid::world::PhysicsWorld;
1284
1285        let dir = std::env::temp_dir().join(format!("gizmo_probe_{}", std::process::id()));
1286        std::fs::create_dir_all(&dir).unwrap();
1287        let path = dir.join("probe.lua");
1288        std::fs::write(
1289            &path,
1290            "function on_update(c)\n             \x20 print('on_slab=' .. tostring(physics.ground_at(0.0, 0.0)))\n             \x20 print('off_slab=' .. tostring(physics.ground_at(500.0, 500.0)))\n             end\n",
1291        )
1292        .unwrap();
1293
1294        // A floor slab whose top sits at y = 2.
1295        use gizmo_math::Vec3;
1296        use gizmo_physics_core::{BodyHandle, Collider, Transform};
1297        use gizmo_physics_rigid::{RigidBody, Velocity};
1298
1299        let mut world = World::new();
1300        let mut pw = PhysicsWorld::new();
1301        pw.add_body(
1302            BodyHandle::from_id(0),
1303            RigidBody::new_static(),
1304            Transform::new(Vec3::new(0.0, 0.0, 0.0)),
1305            Velocity::default(),
1306            Collider::box_collider(Vec3::new(50.0, 2.0, 50.0)),
1307        );
1308        world.insert_resource(pw);
1309
1310        let mut engine = ScriptEngine::new().unwrap();
1311        engine.load_script(path.to_str().unwrap()).unwrap();
1312        engine.update(&world, &Input::default(), 0.016).unwrap();
1313
1314        let log = engine.log_queue.lock().unwrap().clone();
1315        let line = |k: &str| {
1316            log.iter()
1317                .find_map(|(_, m)| m.strip_prefix(k).map(str::to_string))
1318                .unwrap_or_else(|| panic!("no `{k}` line in {log:?}"))
1319        };
1320        let on_slab: f32 = line("on_slab=").parse().expect("a height over the slab");
1321        assert!((on_slab - 2.0).abs() < 0.01, "expected the slab top at 2.0, got {on_slab}");
1322        assert_eq!(line("off_slab="), "nil", "no floor there must read as nil, not as zero");
1323        std::fs::remove_dir_all(&dir).ok();
1324    }
1325
1326    /// …and the borrow does not outlive the frame: the name is gone once the scope closes, so a
1327    /// script that saved it cannot call into a world that is no longer there.
1328    #[test]
1329    fn the_call_time_query_is_not_available_outside_the_frame() {
1330        let lua = Lua::new();
1331        crate::api_physics::register_physics_api(&lua, Arc::new(CommandQueue::new())).unwrap();
1332        let world = World::new();
1333
1334        crate::api_physics::with_call_time_queries(&lua, &world, || {
1335            let present: bool = lua.load("return physics.ground_at ~= nil").eval()?;
1336            assert!(present, "the query must exist while the frame is running");
1337            Ok(())
1338        })
1339        .unwrap();
1340
1341        let present: bool = lua.load("return physics.ground_at ~= nil").eval().unwrap();
1342        assert!(!present, "the query must be gone once the frame is over");
1343    }
1344
1345    /// A script that never returns must lose its frame, not the process.
1346    ///
1347    /// `while true do end` in a Lua VM the host has no timeout on is unrecoverable: the call never
1348    /// returns, so the frame never ends, so the window never redraws and never processes the
1349    /// close event either. There is no signal to catch and no watchdog thread that could help —
1350    /// only the VM can interrupt itself, which is what the instruction hook is for.
1351    #[test]
1352    fn an_infinite_loop_ends_the_call_instead_of_the_process() {
1353        let dir = std::env::temp_dir().join(format!("gizmo_budget_{}", std::process::id()));
1354        std::fs::create_dir_all(&dir).unwrap();
1355        let path = dir.join("runaway.lua");
1356        std::fs::write(&path, "function on_update(ctx)\n  while true do end\nend\n").unwrap();
1357
1358        let mut engine = ScriptEngine::new().unwrap();
1359        // Small enough to trip in milliseconds; the default is a runaway guard, not a stopwatch.
1360        engine.set_instruction_budget(200_000);
1361        engine.load_script(path.to_str().unwrap()).unwrap();
1362
1363        let world = World::new();
1364        let input = Input::default();
1365        let started = std::time::Instant::now();
1366        let err = engine.update(&world, &input, 0.016).unwrap_err();
1367        let took = started.elapsed();
1368
1369        assert!(err.contains("instruction budget"), "unexpected error: {err}");
1370        assert!(took.as_secs() < 5, "the guard took {took:?} — that is a hang with extra steps");
1371        std::fs::remove_dir_all(&dir).ok();
1372    }
1373
1374    /// The budget is per call, so the runaway script loses its own frame and the next one still
1375    /// runs — the same isolation the error handling already gives a script that throws.
1376    #[test]
1377    fn a_runaway_script_does_not_spend_another_scripts_budget() {
1378        let dir = std::env::temp_dir().join(format!("gizmo_budget2_{}", std::process::id()));
1379        std::fs::create_dir_all(&dir).unwrap();
1380        // `a_` sorts before `b_`, and the script map is ordered by path, so the runaway runs first.
1381        let runaway = dir.join("a_runaway.lua");
1382        let neighbour = dir.join("b_neighbour.lua");
1383        std::fs::write(&runaway, "function on_update(ctx)\n  while true do end\nend\n").unwrap();
1384        // Observable through the log queue rather than a new accessor: `print` already routes
1385        // into it, so the test needs no API the engine would not otherwise have.
1386        std::fs::write(&neighbour, "function on_update(ctx)\n  print('neighbour ran')\nend\n")
1387            .unwrap();
1388
1389        let mut engine = ScriptEngine::new().unwrap();
1390        engine.set_instruction_budget(200_000);
1391        engine.load_script(runaway.to_str().unwrap()).unwrap();
1392        engine.load_script(neighbour.to_str().unwrap()).unwrap();
1393
1394        let world = World::new();
1395        let input = Input::default();
1396        let err = engine.update(&world, &input, 0.016).unwrap_err();
1397        assert!(err.contains("instruction budget"), "unexpected error: {err}");
1398
1399        let logged = engine
1400            .log_queue
1401            .lock()
1402            .unwrap()
1403            .iter()
1404            .any(|(_, m)| m.contains("neighbour ran"));
1405        assert!(logged, "the second script never got its turn");
1406        std::fs::remove_dir_all(&dir).ok();
1407    }
1408
1409    /// A script that allocates without bound hits a Lua error, not the OOM killer.
1410    #[test]
1411    fn runaway_allocation_fails_as_a_lua_error() {
1412        let dir = std::env::temp_dir().join(format!("gizmo_mem_{}", std::process::id()));
1413        std::fs::create_dir_all(&dir).unwrap();
1414        let path = dir.join("hungry.lua");
1415        std::fs::write(
1416            &path,
1417            "function on_update(ctx)\n  local t = {}\n  while true do t[#t+1] = string.rep('x', 1024) end\nend\n",
1418        )
1419        .unwrap();
1420
1421        let mut engine = ScriptEngine::new().unwrap();
1422        engine.set_memory_limit(4 * 1024 * 1024).unwrap();
1423        // Generous, so the memory ceiling is what stops it rather than the instruction budget.
1424        engine.set_instruction_budget(500_000_000);
1425        engine.load_script(path.to_str().unwrap()).unwrap();
1426
1427        let err = engine.update(&World::new(), &Input::default(), 0.016).unwrap_err();
1428        assert!(
1429            err.to_lowercase().contains("memory"),
1430            "expected a memory error, got: {err}"
1431        );
1432        std::fs::remove_dir_all(&dir).ok();
1433    }
1434    use super::*;
1435    use gizmo_math::{Quat, Vec3};
1436    use gizmo_physics_core::{Collider, ColliderShape, Transform};
1437    use gizmo_physics_rigid::components::{RigidBody, Velocity};
1438
1439    /// Paralel test koşumlarında çakışmayan benzersiz geçici script yolu üretir.
1440    fn unique_temp(tag: &str) -> String {
1441        use std::sync::atomic::{AtomicU64, Ordering};
1442        static N: AtomicU64 = AtomicU64::new(0);
1443        let n = N.fetch_add(1, Ordering::Relaxed);
1444        let nanos = std::time::SystemTime::now()
1445            .duration_since(std::time::UNIX_EPOCH)
1446            .unwrap()
1447            .as_nanos();
1448        std::env::temp_dir()
1449            .join(format!("gizmo_scripting_{tag}_{n}_{nanos}.lua"))
1450            .to_string_lossy()
1451            .into_owned()
1452    }
1453
1454    /// A top-level `on_update` in a loaded script must fire every frame. It's written
1455    /// into the script's isolated env, so the old code that read `on_update` from
1456    /// `_G` never found it and the hook was a silent no-op.
1457    #[test]
1458    fn on_update_hook_fires_from_script_env() {
1459        let mut engine = ScriptEngine::new().unwrap();
1460        let world = World::new();
1461        let input = gizmo_core::input::Input::default();
1462
1463        let path = std::env::temp_dir()
1464            .join("gizmo_on_update_test.lua")
1465            .to_string_lossy()
1466            .into_owned();
1467        std::fs::write(&path, "function on_update(ctx)\n  entity.spawn(\"bullet\", 0, 0, 0)\nend\n")
1468            .unwrap();
1469        engine.load_script(&path).expect("load_script");
1470
1471        let before = engine.command_queue().len();
1472        engine.update(&world, &input, 1.0 / 60.0).expect("update");
1473        let after = engine.command_queue().len();
1474        let _ = std::fs::remove_file(&path);
1475
1476        assert!(
1477            after > before,
1478            "on_update must run and queue a spawn command (before={before}, after={after})"
1479        );
1480    }
1481
1482    /// Regression: RigidBody var ama Velocity yoksa ApplyForce sessizce
1483    /// kaybolmamalı; Velocity oluşturulup ivme uygulanmalı.
1484    #[test]
1485    fn apply_force_creates_velocity_when_missing() {
1486        let engine = ScriptEngine::new().unwrap();
1487        let mut world = World::new();
1488
1489        let entity = world.spawn();
1490        world.add_component(entity, RigidBody::new(2.0, false));
1491        // Kasıtlı olarak Velocity EKLENMEDİ.
1492        assert!(world.borrow::<Velocity>().get(entity.id()).is_none());
1493
1494        engine
1495            .command_queue()
1496            .push(ScriptCommand::ApplyForce(entity.id(), Vec3::new(4.0, 0.0, 0.0)));
1497
1498        let dt = 0.5_f32;
1499        engine.flush_commands(&mut world, dt);
1500
1501        let vels = world.borrow::<Velocity>();
1502        let v = vels
1503            .get(entity.id())
1504            .expect("Velocity ApplyForce tarafından oluşturulmalıydı");
1505        // accel = force/mass = 4/2 = 2; dv = accel*dt = 2*0.5 = 1.0
1506        assert!((v.linear.x - 1.0).abs() < 1e-5, "x hızı yanlış: {}", v.linear.x);
1507    }
1508
1509    /// Regression: RigidBody var ama Velocity yoksa ApplyImpulse sessizce
1510    /// kaybolmamalı; Velocity oluşturulup delta-v uygulanmalı.
1511    #[test]
1512    fn apply_impulse_creates_velocity_when_missing() {
1513        let engine = ScriptEngine::new().unwrap();
1514        let mut world = World::new();
1515
1516        let entity = world.spawn();
1517        world.add_component(entity, RigidBody::new(2.0, false));
1518        assert!(world.borrow::<Velocity>().get(entity.id()).is_none());
1519
1520        engine
1521            .command_queue()
1522            .push(ScriptCommand::ApplyImpulse(entity.id(), Vec3::new(6.0, 0.0, 0.0)));
1523
1524        engine.flush_commands(&mut world, 0.016);
1525
1526        let vels = world.borrow::<Velocity>();
1527        let v = vels
1528            .get(entity.id())
1529            .expect("Velocity ApplyImpulse tarafından oluşturulmalıydı");
1530        // dv = impulse/mass = 6/2 = 3.0 (dt'den bağımsız)
1531        assert!((v.linear.x - 3.0).abs() < 1e-5, "x hızı yanlış: {}", v.linear.x);
1532    }
1533
1534    /// Transform yazma komutları (SetPosition/SetScale/SetRotation) mevcut bir
1535    /// Transform'a uygulanmalı.
1536    #[test]
1537    fn transform_commands_apply_to_component() {
1538        let engine = ScriptEngine::new().unwrap();
1539        let mut world = World::new();
1540        let e = world.spawn();
1541        world.add_component(e, Transform::new(Vec3::ZERO));
1542        let id = e.id();
1543
1544        engine.command_queue().push(ScriptCommand::SetPosition(id, Vec3::new(1.0, 2.0, 3.0)));
1545        engine.command_queue().push(ScriptCommand::SetScale(id, Vec3::new(2.0, 4.0, 8.0)));
1546        engine.command_queue().push(ScriptCommand::SetRotation(id, Quat::from_xyzw(1.0, 0.0, 0.0, 0.0)));
1547        engine.flush_commands(&mut world, 0.016);
1548
1549        let transforms = world.borrow::<Transform>();
1550        let t = transforms.get(id).unwrap();
1551        assert_eq!(t.position, Vec3::new(1.0, 2.0, 3.0));
1552        assert_eq!(t.scale, Vec3::new(2.0, 4.0, 8.0));
1553        assert!((t.rotation.x - 1.0).abs() < 1e-6 && t.rotation.w.abs() < 1e-6);
1554    }
1555
1556    /// SetVelocity/SetAngularVelocity mevcut Velocity'nin linear/angular alanlarını ayarlamalı.
1557    #[test]
1558    fn velocity_commands_apply_to_component() {
1559        let engine = ScriptEngine::new().unwrap();
1560        let mut world = World::new();
1561        let e = world.spawn();
1562        world.add_component(e, Velocity::new(Vec3::ZERO));
1563        let id = e.id();
1564
1565        engine.command_queue().push(ScriptCommand::SetVelocity(id, Vec3::new(3.0, 0.0, -2.0)));
1566        engine.command_queue().push(ScriptCommand::SetAngularVelocity(id, Vec3::new(0.0, 1.0, 0.0)));
1567        engine.flush_commands(&mut world, 0.016);
1568
1569        let vels = world.borrow::<Velocity>();
1570        let v = vels.get(id).unwrap();
1571        assert_eq!(v.linear, Vec3::new(3.0, 0.0, -2.0));
1572        assert_eq!(v.angular, Vec3::new(0.0, 1.0, 0.0));
1573    }
1574
1575    /// Kütlesi sıfır (statik) bir gövdeye kuvvet uygulanınca Velocity OLUŞTURULMAMALI —
1576    /// `mass > 0.0` koruması sonsuz ivmeyi engeller.
1577    #[test]
1578    fn apply_force_on_zero_mass_creates_no_velocity() {
1579        let engine = ScriptEngine::new().unwrap();
1580        let mut world = World::new();
1581        let e = world.spawn();
1582        world.add_component(e, RigidBody::new(0.0, false));
1583        let id = e.id();
1584
1585        engine.command_queue().push(ScriptCommand::ApplyForce(id, Vec3::new(100.0, 0.0, 0.0)));
1586        engine.flush_commands(&mut world, 0.016);
1587
1588        assert!(
1589            world.borrow::<Velocity>().get(id).is_none(),
1590            "sıfır kütle için Velocity oluşturulmamalı"
1591        );
1592    }
1593
1594    /// Aynı flush içinde birden çok kuvvet birikimli (superposition) uygulanmalı.
1595    #[test]
1596    fn multiple_forces_accumulate_in_one_flush() {
1597        let engine = ScriptEngine::new().unwrap();
1598        let mut world = World::new();
1599        let e = world.spawn();
1600        world.add_component(e, RigidBody::new(2.0, false));
1601        world.add_component(e, Velocity::new(Vec3::ZERO));
1602        let id = e.id();
1603
1604        engine.command_queue().push(ScriptCommand::ApplyForce(id, Vec3::new(4.0, 0.0, 0.0)));
1605        engine.command_queue().push(ScriptCommand::ApplyForce(id, Vec3::new(0.0, 6.0, 0.0)));
1606        engine.flush_commands(&mut world, 0.5);
1607
1608        let vels = world.borrow::<Velocity>();
1609        let v = vels.get(id).unwrap();
1610        // dv = (F/m)*dt : x = 4/2*0.5 = 1.0 ; y = 6/2*0.5 = 1.5
1611        assert!((v.linear.x - 1.0).abs() < 1e-5, "x: {}", v.linear.x);
1612        assert!((v.linear.y - 1.5).abs() < 1e-5, "y: {}", v.linear.y);
1613    }
1614
1615    /// AddRigidBody hareket edebilmesi için beraberinde bir Velocity de oluşturmalı.
1616    #[test]
1617    fn add_rigidbody_also_creates_velocity() {
1618        let engine = ScriptEngine::new().unwrap();
1619        let mut world = World::new();
1620        let e = world.spawn();
1621        let id = e.id();
1622
1623        engine.command_queue().push(ScriptCommand::AddRigidBody { id, mass: 3.0, use_gravity: true });
1624        engine.flush_commands(&mut world, 0.016);
1625
1626        let rbs = world.borrow::<RigidBody>();
1627        assert!((rbs.get(id).unwrap().mass - 3.0).abs() < 1e-6);
1628        drop(rbs);
1629        assert!(
1630            world.borrow::<Velocity>().get(id).is_some(),
1631            "AddRigidBody Velocity de eklemeli"
1632        );
1633    }
1634
1635    /// AddBoxCollider/AddSphereCollider doğru şekilli Collider bileşenleri oluşturmalı.
1636    #[test]
1637    fn colliders_are_created_with_correct_shape() {
1638        let engine = ScriptEngine::new().unwrap();
1639        let mut world = World::new();
1640        let e_box = world.spawn();
1641        let e_sphere = world.spawn();
1642        let (bid, sid) = (e_box.id(), e_sphere.id());
1643
1644        engine.command_queue().push(ScriptCommand::AddBoxCollider { id: bid, hx: 1.0, hy: 2.0, hz: 3.0 });
1645        engine.command_queue().push(ScriptCommand::AddSphereCollider { id: sid, radius: 4.0 });
1646        engine.flush_commands(&mut world, 0.016);
1647
1648        let cols = world.borrow::<Collider>();
1649        match &cols.get(bid).unwrap().shape {
1650            ColliderShape::Box(b) => assert_eq!(b.half_extents, Vec3::new(1.0, 2.0, 3.0)),
1651            other => panic!("beklenen Box, gelen {other:?}"),
1652        }
1653        match &cols.get(sid).unwrap().shape {
1654            ColliderShape::Sphere(s) => assert!((s.radius - 4.0).abs() < 1e-6),
1655            other => panic!("beklenen Sphere, gelen {other:?}"),
1656        }
1657    }
1658
1659    /// SpawnEntity: isimli, Transform'lu bir entity oluşturmalı ve log kuyruğuna kayıt düşmeli.
1660    #[test]
1661    fn spawn_entity_creates_named_transform_and_logs() {
1662        let engine = ScriptEngine::new().unwrap();
1663        let mut world = World::new();
1664
1665        let logs_before = engine.log_queue.lock().unwrap().len();
1666        engine
1667            .command_queue()
1668            .push(ScriptCommand::SpawnEntity { name: "hero".into(), position: Vec3::new(5.0, 6.0, 7.0) });
1669        engine.flush_commands(&mut world, 0.016);
1670
1671        // İsimli entity'yi bul.
1672        let names = world.borrow::<gizmo_core::EntityName>();
1673        let found = names.iter().filter_map(|(eid, _)| names.get(eid).map(|n| (eid, n.0.clone())))
1674            .find(|(_, name)| name == "hero");
1675        let (eid, _) = found.expect("'hero' isimli entity oluşmalıydı");
1676        drop(names);
1677
1678        let transforms = world.borrow::<Transform>();
1679        assert_eq!(transforms.get(eid).unwrap().position, Vec3::new(5.0, 6.0, 7.0));
1680        drop(transforms);
1681
1682        assert!(
1683            engine.log_queue.lock().unwrap().len() > logs_before,
1684            "spawn log kuyruğuna kayıt düşmeliydi"
1685        );
1686    }
1687
1688    /// DestroyEntity var olan bir entity'yi despawn etmeli (artık canlı olmamalı).
1689    #[test]
1690    fn destroy_entity_removes_it() {
1691        let engine = ScriptEngine::new().unwrap();
1692        let mut world = World::new();
1693        let e = world.spawn();
1694        let id = e.id();
1695        assert!(world.entity(id).is_some());
1696
1697        engine.command_queue().push(ScriptCommand::DestroyEntity(id));
1698        engine.flush_commands(&mut world, 0.016);
1699
1700        assert!(world.entity(id).is_none(), "entity despawn edilmeliydi");
1701    }
1702
1703    /// SetEntityName mevcut EntityName'i yeniden adlandırmalı.
1704    #[test]
1705    fn set_entity_name_renames() {
1706        let engine = ScriptEngine::new().unwrap();
1707        let mut world = World::new();
1708        let e = world.spawn();
1709        world.add_component(e, gizmo_core::EntityName::new("old"));
1710        let id = e.id();
1711
1712        engine.command_queue().push(ScriptCommand::SetEntityName(id, "new".into()));
1713        engine.flush_commands(&mut world, 0.016);
1714
1715        let names = world.borrow::<gizmo_core::EntityName>();
1716        assert_eq!(names.get(id).unwrap().0, "new");
1717    }
1718
1719    /// AddNavAgent + SetAiTarget hedefi ayarlamalı; ClearAiTarget hedefi (yalnız yolu değil) temizlemeli.
1720    #[test]
1721    fn nav_agent_target_set_then_cleared() {
1722        use gizmo_ai::components::NavAgent;
1723        let engine = ScriptEngine::new().unwrap();
1724        let mut world = World::new();
1725        let e = world.spawn();
1726        let id = e.id();
1727
1728        engine.command_queue().push(ScriptCommand::AddNavAgent(id));
1729        engine.command_queue().push(ScriptCommand::SetAiTarget(id, Vec3::new(9.0, 0.0, 0.0)));
1730        engine.flush_commands(&mut world, 0.016);
1731        {
1732            let agents = world.borrow::<NavAgent>();
1733            assert_eq!(agents.get(id).unwrap().target, Some(Vec3::new(9.0, 0.0, 0.0)));
1734        }
1735
1736        engine.command_queue().push(ScriptCommand::ClearAiTarget(id));
1737        engine.flush_commands(&mut world, 0.016);
1738        {
1739            let agents = world.borrow::<NavAgent>();
1740            assert_eq!(agents.get(id).unwrap().target, None, "hedef temizlenmeliydi");
1741        }
1742    }
1743
1744    /// Uygulanmayan her komut çağırana geri döndürülmeli — sessizce yutulmamalı.
1745    ///
1746    /// **Bu test eskiden kusuru sabitliyordu.** Adı `..._but_consumes_savescene_and_vehicle` idi ve
1747    /// `SaveScene` ile araç komutlarının *dönmemesini* iddia ediyordu. Oysa onları yutan kol,
1748    /// yorumunda "bunlar zaten unhandled'a düşecek" diyordu — düşemezlerdi, çünkü kolun kendisi
1749    /// onları tüketiyordu. Lua tarafında canlı fonksiyonları olan bir komutun hiçbir iz bırakmadan
1750    /// kaybolması, bir script yazarının teşhis edemeyeceği tek şeydir. Artık iddia niyet: bu crate
1751    /// uygulayamadığı komutu ev sahibine verir.
1752    #[test]
1753    fn flush_returns_everything_it_cannot_apply_itself() {
1754        let engine = ScriptEngine::new().unwrap();
1755        let mut world = World::new();
1756
1757        let cq = engine.command_queue();
1758        cq.push(ScriptCommand::PlaySound("boom".into()));
1759        cq.push(ScriptCommand::PlaySound3D("bird".into(), Vec3::ZERO));
1760        cq.push(ScriptCommand::StopSound("music".into()));
1761        cq.push(ScriptCommand::LoadScene("level.scene".into()));
1762        cq.push(ScriptCommand::SaveScene("slot.scene".into()));
1763        cq.push(ScriptCommand::SetVehicleBrake(1, 500.0));
1764
1765        let unhandled = engine.flush_commands(&mut world, 0.016);
1766
1767        assert_eq!(unhandled.len(), 6, "ses(3) + LoadScene + SaveScene + araç(1) — hepsi dönmeli");
1768        assert!(unhandled.iter().any(|c| matches!(c, ScriptCommand::PlaySound(n) if n == "boom")));
1769        assert!(unhandled.iter().any(|c| matches!(c, ScriptCommand::PlaySound3D(n, _) if n == "bird")));
1770        assert!(unhandled.iter().any(|c| matches!(c, ScriptCommand::StopSound(n) if n == "music")));
1771        assert!(unhandled.iter().any(|c| matches!(c, ScriptCommand::LoadScene(n) if n == "level.scene")));
1772        assert!(
1773            unhandled.iter().any(|c| matches!(c, ScriptCommand::SaveScene(n) if n == "slot.scene")),
1774            "SaveScene sessizce yutulmamalı"
1775        );
1776        assert!(
1777            unhandled.iter().any(|c| matches!(c, ScriptCommand::SetVehicleBrake(1, _))),
1778            "araç komutları sessizce yutulmamalı — bu crate onları uygulayamıyor, ev sahibi uygular"
1779        );
1780    }
1781
1782    /// Scriptler her koşuda aynı sırada çalışmalı.
1783    ///
1784    /// `loaded_scripts` bir `std::collections::HashMap` idi, ve `RandomState` proses başına
1785    /// tohumlanır — yani `update`'in scriptleri çalıştırma sırası koşudan koşuya değişiyordu. Aynı
1786    /// varlığa dokunan iki script çeliştiğinde sonucu ekleme sırası belirler, ve bu motorun manşet
1787    /// sözleşmesi aynı-platform bit-birebir tekrar oynatma. Sıra artık scriptlerin yollarının bir
1788    /// özelliği, ayırıcının değil.
1789    #[test]
1790    fn scripts_run_in_a_stable_order() {
1791        let mut engine = ScriptEngine::new().unwrap();
1792        let dir = std::env::temp_dir();
1793        // Loaded in an order that is not the sorted one, so a map that preserved insertion order
1794        // would also fail this.
1795        let mut written = Vec::new();
1796        for stem in ["zebra", "alpha", "midori", "beta"] {
1797            let path = dir
1798                .join(format!("gizmo_order_{stem}.lua"))
1799                .to_string_lossy()
1800                .into_owned();
1801            std::fs::write(&path, "function on_update(ctx) end\n").unwrap();
1802            engine.load_script(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
1803            written.push(path);
1804        }
1805
1806        let order: Vec<String> = engine.loaded_scripts.keys().cloned().collect();
1807        let mut sorted = order.clone();
1808        sorted.sort();
1809        assert_eq!(
1810            order, sorted,
1811            "çalışma sırası yola göre sabit olmalı — bir HashMap'te bu proses başına değişirdi"
1812        );
1813
1814        for path in written {
1815            let _ = std::fs::remove_file(path);
1816        }
1817    }
1818
1819    /// flush_commands kuyruğu tüketmeli (drain): çağrı sonrası kuyruk boş olmalı.
1820    #[test]
1821    fn flush_drains_the_queue() {
1822        let engine = ScriptEngine::new().unwrap();
1823        let mut world = World::new();
1824        engine.command_queue().push(ScriptCommand::StartRace);
1825        engine.command_queue().push(ScriptCommand::HideDialogue);
1826        assert_eq!(engine.command_queue().len(), 2);
1827
1828        engine.flush_commands(&mut world, 0.016);
1829        assert!(engine.command_queue().is_empty(), "flush kuyruğu boşaltmalı");
1830    }
1831
1832    /// Script::new her zaman initialized=false ile başlar (on_init henüz çağrılmadı).
1833    #[test]
1834    fn script_new_starts_uninitialized() {
1835        let s = Script::new("scripts/player.lua");
1836        assert_eq!(s.file_path, "scripts/player.lua");
1837        assert!(!s.initialized);
1838    }
1839
1840    /// Script serde round-trip: `initialized` alanı `#[serde(default, skip)]` olduğundan
1841    /// serileştirmede yer almaz ve deserialize sonrası daima false olur — böylece sahne
1842    /// yüklendiğinde on_init yeniden çalışır. file_path korunmalı.
1843    /// A script's `properties = { … }` declaration is what the editor lists.
1844    ///
1845    /// Read back out of the script's own environment, which is where a bare assignment lands.
1846    /// Non-scalar entries are dropped rather than guessed at: a nested table is the script's own
1847    /// business and has no inspector row.
1848    #[test]
1849    fn declared_properties_are_read_from_the_script() {
1850        let mut engine = ScriptEngine::new().unwrap();
1851        let path = unique_temp("declared_props");
1852        std::fs::write(
1853            &path,
1854            r#"
1855properties = {
1856    open_speed = 2.4,
1857    locked = false,
1858    label = "gate",
1859    nested = { nope = 1 },
1860}
1861"#,
1862        )
1863        .unwrap();
1864        engine.load_script(&path).unwrap();
1865
1866        let declared = engine.declared_properties(&path);
1867        assert_eq!(declared.get("open_speed"), Some(&ScriptValue::Num(2.4)));
1868        assert_eq!(declared.get("locked"), Some(&ScriptValue::Bool(false)));
1869        assert_eq!(declared.get("label"), Some(&ScriptValue::Text("gate".into())));
1870        assert!(
1871            !declared.contains_key("nested"),
1872            "a table is not an inspector row and must not be guessed at"
1873        );
1874        let _ = std::fs::remove_file(&path);
1875    }
1876
1877    /// A script with no declaration yields nothing, rather than erroring.
1878    #[test]
1879    fn a_script_without_properties_declares_none() {
1880        let mut engine = ScriptEngine::new().unwrap();
1881        let path = unique_temp("no_props");
1882        std::fs::write(&path, "function on_entity_update(id, dt, props) end\n").unwrap();
1883        engine.load_script(&path).unwrap();
1884        assert!(engine.declared_properties(&path).is_empty());
1885        let _ = std::fs::remove_file(&path);
1886    }
1887
1888    /// The per-entity values reach the script, and two entities running the same file see their
1889    /// own.
1890    ///
1891    /// This is the whole reason the values live on the component: scripts are loaded per PATH, so
1892    /// both entities below share one Lua environment. If the properties lived in that environment
1893    /// the second call would overwrite the first.
1894    #[test]
1895    fn each_entity_sees_its_own_property_values() {
1896        let mut engine = ScriptEngine::new().unwrap();
1897        let path = unique_temp("per_entity_props");
1898        std::fs::write(
1899            &path,
1900            r#"
1901seen = {}
1902function on_entity_update(id, dt, props)
1903    seen[id] = props.open_speed
1904end
1905"#,
1906        )
1907        .unwrap();
1908        engine.load_script(&path).unwrap();
1909
1910        let mut a = std::collections::BTreeMap::new();
1911        a.insert("open_speed".to_string(), ScriptValue::Num(1.5));
1912        let mut b = std::collections::BTreeMap::new();
1913        b.insert("open_speed".to_string(), ScriptValue::Num(9.25));
1914
1915        engine.update_entity(1, &path, 0.016, &a).unwrap();
1916        engine.update_entity(2, &path, 0.016, &b).unwrap();
1917
1918        let seen_1 = engine.eval_number(&path, "seen[1]").expect("entity 1 value");
1919        let seen_2 = engine.eval_number(&path, "seen[2]").expect("entity 2 value");
1920        assert_eq!(seen_1, 1.5);
1921        assert_eq!(
1922            seen_2, 9.25,
1923            "the second entity saw the first one's value — the properties are being shared"
1924        );
1925        let _ = std::fs::remove_file(&path);
1926    }
1927
1928    /// **Every** stored value reaches the script — declared or not, right type or not.
1929    ///
1930    /// `update_entity` takes the component's whole `properties` map and the studio hands it
1931    /// `script.properties.clone()`, so nothing between the scene file and Lua filters it. That is
1932    /// the contract, and it is a reasonable one — a scene may carry per-entity data a script reads
1933    /// without declaring.
1934    ///
1935    /// It is pinned here because the editor once claimed the opposite. `ScriptValue::kind`'s note
1936    /// said an override whose kind differs from the declaration "is ignored rather than coerced",
1937    /// and the inspector duly filtered such an override out of its *display* — while the script
1938    /// went on receiving it. The inspector showed the declared default and the script ran on the
1939    /// stale value. Whatever the editor draws has to agree with this test, not the other way
1940    /// round.
1941    #[test]
1942    fn every_stored_property_reaches_the_script_declared_or_not() {
1943        let mut engine = ScriptEngine::new().unwrap();
1944        let path = unique_temp("undeclared_props");
1945        std::fs::write(
1946            &path,
1947            r#"
1948properties = { open_speed = 2.4, locked = false }
1949seen_speed = nil
1950seen_locked_is_string = nil
1951seen_undeclared = nil
1952function on_entity_update(id, dt, props)
1953    seen_speed = props.open_speed
1954    seen_locked_is_string = (type(props.locked) == "string") and 1 or 0
1955    seen_undeclared = props.nobody_declared_me
1956end
1957"#,
1958        )
1959        .unwrap();
1960        engine.load_script(&path).unwrap();
1961
1962        // The declaration says `locked` is a bool and knows nothing about `nobody_declared_me`.
1963        let declared = engine.declared_properties(&path);
1964        assert_eq!(declared.get("locked").map(|v| v.kind()), Some("bool"));
1965        assert!(!declared.contains_key("nobody_declared_me"));
1966
1967        let mut stored = std::collections::BTreeMap::new();
1968        stored.insert("open_speed".to_string(), ScriptValue::Num(7.5));
1969        // A stale override: the script now declares this as a bool.
1970        stored.insert("locked".to_string(), ScriptValue::Text("yes".to_string()));
1971        // And a key the script never declared at all.
1972        stored.insert("nobody_declared_me".to_string(), ScriptValue::Num(42.0));
1973
1974        engine.update_entity(1, &path, 0.016, &stored).unwrap();
1975
1976        assert_eq!(engine.eval_number(&path, "seen_speed"), Some(7.5));
1977        assert_eq!(
1978            engine.eval_number(&path, "seen_locked_is_string"),
1979            Some(1.0),
1980            "the type-mismatched override is handed to the script verbatim — it is NOT ignored"
1981        );
1982        assert_eq!(
1983            engine.eval_number(&path, "seen_undeclared"),
1984            Some(42.0),
1985            "an undeclared key reaches the script too, so the editor must not pretend it is absent"
1986        );
1987        let _ = std::fs::remove_file(&path);
1988    }
1989
1990    #[test]
1991    fn script_serde_roundtrip_resets_initialized() {
1992        let mut s = Script::new("a.lua");
1993        s.initialized = true;
1994
1995        let json = serde_json::to_string(&s).unwrap();
1996        assert!(!json.contains("initialized"), "skip'li alan JSON'da olmamalı: {json}");
1997
1998        let back: Script = serde_json::from_str(&json).unwrap();
1999        assert_eq!(back.file_path, "a.lua");
2000        assert!(!back.initialized, "deserialize sonrası initialized=false olmalı");
2001    }
2002
2003    /// Güvenlik: motor tehlikeli global'leri (os/io/require/dofile/loadfile/package/
2004    /// debug/load/loadstring) devre dışı bırakmalı.
2005    #[test]
2006    fn sandbox_disables_dangerous_globals() {
2007        let mut engine = ScriptEngine::new().unwrap();
2008        let path = unique_temp("sandbox");
2009        std::fs::write(
2010            &path,
2011            r#"
2012            assert(os == nil, "os kapatılmalı")
2013            assert(io == nil, "io kapatılmalı")
2014            assert(require == nil, "require kapatılmalı")
2015            assert(dofile == nil, "dofile kapatılmalı")
2016            assert(loadfile == nil, "loadfile kapatılmalı")
2017            assert(package == nil, "package kapatılmalı")
2018            assert(debug == nil, "debug kapatılmalı")
2019            assert(load == nil, "load kapatılmalı")
2020            assert(loadstring == nil, "loadstring kapatılmalı")
2021            "#,
2022        )
2023        .unwrap();
2024        let res = engine.load_script(&path);
2025        let _ = std::fs::remove_file(&path);
2026        res.expect("sandbox assert'leri geçmeli (global'ler nil olmalı)");
2027    }
2028
2029    /// Motorun kaydettiği Lua matematik yardımcıları (vec3_*, clamp, lerp) doğru çalışmalı.
2030    #[test]
2031    fn lua_math_helpers_are_correct() {
2032        let mut engine = ScriptEngine::new().unwrap();
2033        let path = unique_temp("mathhelpers");
2034        std::fs::write(
2035            &path,
2036            r#"
2037            assert(math.abs(vec3_length(vec3(3,4,0)) - 5.0) < 1e-5, "length 3-4-5")
2038            local c = vec3_cross(vec3(1,0,0), vec3(0,1,0))
2039            assert(c.x == 0 and c.y == 0 and c.z == 1, "x cross y = z")
2040            assert(clamp(5, 0, 3) == 3, "clamp üst sınır")
2041            assert(clamp(-1, 0, 3) == 0, "clamp alt sınır")
2042            assert(clamp(2, 0, 3) == 2, "clamp aralık içi")
2043            assert(lerp(0, 10, 0.5) == 5, "lerp orta")
2044            local n = vec3_normalize(vec3(0,0,0))
2045            assert(n.x == 0 and n.y == 0 and n.z == 0, "sıfır vektör normalize => sıfır")
2046            assert(math.abs(vec3_distance(vec3(0,0,0), vec3(0,3,4)) - 5.0) < 1e-5, "distance")
2047            local d = vec3_dot(vec3(1,2,3), vec3(4,5,6))
2048            assert(d == 32, "dot 1*4+2*5+3*6=32")
2049            "#,
2050        )
2051        .unwrap();
2052        let res = engine.load_script(&path);
2053        let _ = std::fs::remove_file(&path);
2054        res.expect("matematik yardımcı assert'leri geçmeli");
2055    }
2056
2057    /// Hata yolu: var olmayan bir script yüklenince açıklayıcı bir hata dönmeli (panik değil).
2058    #[test]
2059    fn load_missing_file_returns_error() {
2060        let mut engine = ScriptEngine::new().unwrap();
2061        let err = engine
2062            .load_script("/nonexistent/gizmo/definitely_missing_5f2a.lua")
2063            .unwrap_err();
2064        assert!(err.contains("okunamadı"), "okuma hatası mesajı beklenir, gelen: {err}");
2065    }
2066
2067    /// Hata yolu: yüklenmemiş bir script için run_entity_update 'not loaded' hatası vermeli.
2068    #[test]
2069    fn run_entity_update_on_unloaded_script_errors() {
2070        let mut engine = ScriptEngine::new().unwrap();
2071        let ctx = ScriptContext::default();
2072        let err = engine
2073            .run_entity_update("never_loaded.lua", "on_entity_update", &ctx)
2074            .unwrap_err();
2075        assert!(err.contains("not loaded"), "mesaj: {err}");
2076    }
2077
2078    /// run_entity_update ctx'i (pozisyon + dt) Lua'ya geçirmeli ve dönen position tablosunu
2079    /// ScriptResult.new_position olarak çıkarmalı. Var olmayan fonksiyon default döndürmeli.
2080    #[test]
2081    fn run_entity_update_marshals_position_and_extracts_result() {
2082        let mut engine = ScriptEngine::new().unwrap();
2083        let path = unique_temp("marshal_pos");
2084        std::fs::write(
2085            &path,
2086            "function mv(ctx)\n  return { position = { x = ctx.position.x + ctx.dt, y = ctx.position.y, z = ctx.position.z } }\nend\n",
2087        )
2088        .unwrap();
2089        engine.load_script(&path).unwrap();
2090
2091        let ctx = ScriptContext {
2092            entity_id: 42,
2093            dt: 0.5,
2094            position: [10.0, -1.0, 2.0],
2095            ..Default::default()
2096        };
2097
2098        let result = engine.run_entity_update(&path, "mv", &ctx).unwrap();
2099        assert_eq!(result.new_position, Some([10.5, -1.0, 2.0]));
2100        assert_eq!(result.new_velocity, None, "script velocity döndürmedi");
2101
2102        // Var olmayan fonksiyon → default (her ikisi None).
2103        let empty = engine.run_entity_update(&path, "yok_boyle_fn", &ctx).unwrap();
2104        assert_eq!(empty.new_position, None);
2105        assert_eq!(empty.new_velocity, None);
2106
2107        let _ = std::fs::remove_file(&path);
2108    }
2109
2110    /// run_entity_update girdi (input) bayraklarını Lua ctx.input'a geçirmeli; script
2111    /// bunlara göre velocity döndürebilmeli.
2112    #[test]
2113    fn run_entity_update_marshals_input_flags() {
2114        let mut engine = ScriptEngine::new().unwrap();
2115        let path = unique_temp("marshal_input");
2116        std::fs::write(
2117            &path,
2118            "function ctl(ctx)\n  local vx = 0\n  if ctx.input.d then vx = 1 end\n  if ctx.input.a then vx = vx - 1 end\n  return { velocity = { x = vx, y = 0, z = 0 } }\nend\n",
2119        )
2120        .unwrap();
2121        engine.load_script(&path).unwrap();
2122
2123        let mut ctx = ScriptContext {
2124            key_d: true, // sağa
2125            ..Default::default()
2126        };
2127        let r = engine.run_entity_update(&path, "ctl", &ctx).unwrap();
2128        assert_eq!(r.new_velocity, Some([1.0, 0.0, 0.0]));
2129
2130        ctx.key_d = false;
2131        ctx.key_a = true; // sola
2132        let r2 = engine.run_entity_update(&path, "ctl", &ctx).unwrap();
2133        assert_eq!(r2.new_velocity, Some([-1.0, 0.0, 0.0]));
2134
2135        let _ = std::fs::remove_file(&path);
2136    }
2137
2138    /// has_function yalnız yüklü script'te tanımlı fonksiyonlar için true dönmeli.
2139    #[test]
2140    fn has_function_detects_defined_and_missing() {
2141        let mut engine = ScriptEngine::new().unwrap();
2142        let path = unique_temp("hasfn");
2143        std::fs::write(&path, "function on_update(ctx) end\n").unwrap();
2144        engine.load_script(&path).unwrap();
2145
2146        assert!(engine.has_function(&path, "on_update"));
2147        assert!(!engine.has_function(&path, "on_missing"));
2148        assert!(!engine.has_function("unloaded.lua", "on_update"));
2149
2150        let _ = std::fs::remove_file(&path);
2151    }
2152
2153    /// reload_if_changed: içerik değişmediyse false (yeniden yükleme yok), değişince true;
2154    /// sonra tekrar değişmezse yine false — hot-reload durum makinesi.
2155    #[test]
2156    fn reload_if_changed_detects_content_change() {
2157        let mut engine = ScriptEngine::new().unwrap();
2158        let path = unique_temp("reload");
2159        std::fs::write(&path, "function on_update(ctx) end\n").unwrap();
2160        engine.load_script(&path).unwrap();
2161
2162        assert!(!engine.reload_if_changed(&path).unwrap(), "değişmemişken false");
2163
2164        std::fs::write(&path, "function on_update(ctx) end\n-- değişti\n").unwrap();
2165        assert!(engine.reload_if_changed(&path).unwrap(), "değişince true");
2166
2167        assert!(!engine.reload_if_changed(&path).unwrap(), "tekrar değişmemişken false");
2168
2169        let _ = std::fs::remove_file(&path);
2170    }
2171}