Skip to main content

gizmo_scripting/
commands.rs

1//! Script Command Queue — Lua scriptlerden gelen değişiklik isteklerinin biriktirildiği kuyruk
2//!
3//! Lua scriptleri doğrudan World'ü mutate edemez (Rust borrow kuralları).
4//! Bunun yerine komutlar bu kuyrukta birikir ve frame sonunda `flush()` ile uygulanır.
5
6use gizmo_math::{Quat, Vec3};
7use std::sync::Mutex;
8/// Lua'dan gelen tüm değişiklik istekleri
9#[derive(Debug, Clone)]
10#[non_exhaustive]
11pub enum ScriptCommand {
12    // Transform
13    SetPosition(u32, Vec3),
14    SetRotation(u32, Quat),
15    SetScale(u32, Vec3),
16
17    // Velocity
18    SetVelocity(u32, Vec3),
19    SetAngularVelocity(u32, Vec3),
20
21    // Physics
22    ApplyForce(u32, Vec3),
23    ApplyImpulse(u32, Vec3),
24    AddRigidBody {
25        id: u32,
26        mass: f32,
27        use_gravity: bool,
28    },
29    AddBoxCollider {
30        id: u32,
31        hx: f32,
32        hy: f32,
33        hz: f32,
34    },
35    AddSphereCollider {
36        id: u32,
37        radius: f32,
38    },
39
40    // Vehicle
41    SetVehicleEngineForce(u32, f32),
42    SetVehicleSteering(u32, f32),
43    SetVehicleBrake(u32, f32),
44
45    // Entity Lifecycle
46    SpawnEntity {
47        name: String,
48        position: Vec3,
49    },
50    SpawnPrefab {
51        name: String,
52        prefab_type: String,
53        position: Vec3,
54    },
55    DestroyEntity(u32),
56
57    // Audio
58    PlaySound(String),
59    PlaySound3D(String, Vec3),
60    StopSound(String),
61
62    // Scene
63    LoadScene(String),
64    SaveScene(String),
65
66    // Diyalog Sistemi
67    ShowDialogue {
68        speaker: String,
69        text: String,
70        duration: f32,
71    },
72    HideDialogue,
73
74    // Ara Sahne (Cutscene)
75    TriggerCutscene(String), // cutscene adı/id
76    EndCutscene,
77
78    // Yarış Sistemi
79    StartRace,
80    AddCheckpoint {
81        id: u32,
82        position: Vec3,
83        radius: f32,
84    },
85    ActivateCheckpoint(u32),
86    FinishRace {
87        winner_name: String,
88    },
89    ResetRace,
90
91    // Kamera
92    SetCameraTarget(u32), // hangi entity'yi takip etsin
93    SetCameraFov(f32),
94    /// İki dövüşçüyü aynı anda takip eden fighting camera
95    SetFightCamera {
96        p1_id: u32,
97        p2_id: u32,
98        height: f32,     // Kamera yüksekliği (Y offset)
99        distance: f32,   // Minimum uzaklık (Z offset)
100    },
101
102// Component
103    SetEntityName(u32, String),
104PlayAnimation {
105        id: u32,
106        name: String,
107        blend: f32,
108        loop_anim: bool,
109    },
110    SetAnimationSpeed(u32, f32),
111
112
113    // AI
114    AddNavAgent(u32),
115    SetAiTarget(u32, Vec3),
116    ClearAiTarget(u32),
117
118    // Fighter
119    SetFighterMove {
120        id: u32,
121        name: String,
122        startup: u32,
123        active: u32,
124        recovery: u32,
125        damage: f32,
126    },
127    ApplyHitstop(u32, u32),
128    ApplyHitstun(u32, u32),
129}
130
131
132impl ScriptCommand {
133    /// Does every number this command carries have a finite value?
134    ///
135    /// # Why the queue asks
136    ///
137    /// Lua arithmetic produces NaN and infinity quietly — `0/0`, `math.huge`, a division by a
138    /// velocity that happened to be zero — and a script has no reason to notice. Downstream,
139    /// nothing recovers: a NaN position makes an entity vanish and every comparison against it
140    /// false, a NaN velocity poisons the integrator for that body and then, through contacts, for
141    /// whatever it touches, and the determinism hash goes with it. `sanitize_dim` covered collider
142    /// dimensions — one of the eleven variants that carry floats — because that was the one that
143    /// had bitten someone.
144    ///
145    /// The match is **exhaustive on purpose**: no `_` arm, so a variant added with a float in it
146    /// fails to compile here rather than becoming the next one that was not covered. Variants
147    /// carrying no numbers answer `true` by naming themselves, which is the price of that.
148    #[must_use]
149    pub fn is_finite(&self) -> bool {
150        use ScriptCommand::*;
151        match self {
152            SetPosition(_, v) | SetScale(_, v) | SetVelocity(_, v) | SetAngularVelocity(_, v)
153            | ApplyForce(_, v) | ApplyImpulse(_, v) | SetAiTarget(_, v) | PlaySound3D(_, v) => {
154                v.is_finite()
155            }
156            SetRotation(_, q) => q.is_finite(),
157            AddRigidBody { mass, .. } => mass.is_finite(),
158            AddBoxCollider { hx, hy, hz, .. } => {
159                hx.is_finite() && hy.is_finite() && hz.is_finite()
160            }
161            AddSphereCollider { radius, .. } => radius.is_finite(),
162            SetVehicleEngineForce(_, f) | SetVehicleSteering(_, f) | SetVehicleBrake(_, f) => {
163                f.is_finite()
164            }
165            SpawnEntity { position, .. } | SpawnPrefab { position, .. } => position.is_finite(),
166            ShowDialogue { duration, .. } => duration.is_finite(),
167            AddCheckpoint { position, radius, .. } => position.is_finite() && radius.is_finite(),
168            SetCameraFov(f) | SetAnimationSpeed(_, f) => f.is_finite(),
169            SetFightCamera { height, distance, .. } => height.is_finite() && distance.is_finite(),
170            PlayAnimation { blend, .. } => blend.is_finite(),
171            SetFighterMove { damage, .. } => damage.is_finite(),
172
173            // Carry no floating-point numbers. Listed rather than wildcarded — see above.
174            DestroyEntity(_)
175            | PlaySound(_)
176            | StopSound(_)
177            | LoadScene(_)
178            | SaveScene(_)
179            | HideDialogue
180            | TriggerCutscene(_)
181            | EndCutscene
182            | StartRace
183            | ActivateCheckpoint(_)
184            | FinishRace { .. }
185            | ResetRace
186            | SetCameraTarget(_)
187            | SetEntityName(_, _)
188            | AddNavAgent(_)
189            | ClearAiTarget(_)
190            | ApplyHitstop(_, _)
191            | ApplyHitstun(_, _) => true,
192        }
193    }
194}
195
196
197/// Thread-safe queue of pending [`ScriptCommand`]s, accessible from Lua callbacks.
198///
199/// Lua callbacks cannot mutate the `World` directly, so they push commands here;
200/// the engine later drains and applies them at a controlled point in the frame.
201#[derive(Debug, Default)]
202pub struct CommandQueue {
203    /// Pending commands, guarded by a mutex so Lua callbacks can push concurrently.
204    pub commands: Mutex<Vec<ScriptCommand>>,
205    /// How many commands [`push`](CommandQueue::push) has refused for carrying NaN or infinity.
206    /// Cumulative for the life of the queue; a caller that wants a per-frame figure takes
207    /// differences.
208    rejected: std::sync::atomic::AtomicU64,
209}
210
211impl CommandQueue {
212    /// Creates an empty command queue.
213    pub fn new() -> Self {
214        Self {
215            commands: Mutex::new(Vec::new()),
216            rejected: std::sync::atomic::AtomicU64::new(0),
217        }
218    }
219
220    /// Appends a command to the queue, unless it carries a non-finite number.
221    ///
222    /// Dropped rather than clamped: a clamped force is a wrong answer the frame accepts silently,
223    /// while a dropped one is a no-op with a log line and a counter behind it. A script that
224    /// produced NaN has a bug, and the useful thing to do is say so, not to guess what it meant.
225    pub fn push(&self, cmd: ScriptCommand) {
226        if !cmd.is_finite() {
227            tracing::warn!(
228                command = ?cmd,
229                "[Scripting] command dropped: carries NaN or infinity"
230            );
231            self.rejected
232                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
233            return;
234        }
235        // Poison-recovery: bir thread lock tutarken panic etse bile kuyruk
236        // kullanılabilir kalır (FFI/Lua callback sınırında panic-free).
237        self.commands
238            .lock()
239            .unwrap_or_else(|e| e.into_inner())
240            .push(cmd);
241    }
242
243    /// How many commands have been refused for carrying NaN or infinity, since this queue was
244    /// created.
245    #[must_use]
246    pub fn rejected_count(&self) -> u64 {
247        self.rejected.load(std::sync::atomic::Ordering::Relaxed)
248    }
249
250    /// Removes and returns all currently queued commands, leaving the queue empty.
251    pub fn drain(&self) -> Vec<ScriptCommand> {
252        // Poison-recovery: zehirlenmiş mutex'i kurtar, panic etme.
253        self.commands
254            .lock()
255            .unwrap_or_else(|e| e.into_inner())
256            .drain(..)
257            .collect()
258    }
259
260    /// Returns `true` if no commands are currently queued.
261    pub fn is_empty(&self) -> bool {
262        // Poison-recovery: zehirlenmiş mutex'i kurtar, panic etme.
263        self.commands
264            .lock()
265            .unwrap_or_else(|e| e.into_inner())
266            .is_empty()
267    }
268
269    /// Returns the number of currently queued commands.
270    pub fn len(&self) -> usize {
271        // Poison-recovery: zehirlenmiş mutex'i kurtar, panic etme.
272        self.commands
273            .lock()
274            .unwrap_or_else(|e| e.into_inner())
275            .len()
276    }
277}
278
279#[cfg(test)]
280mod tests {
281
282    /// The queue is the one place every command passes, so it is the one place that has to ask.
283    #[test]
284    fn a_command_carrying_nan_never_reaches_the_queue() {
285        let q = CommandQueue::new();
286        q.push(ScriptCommand::SetPosition(1, Vec3::new(f32::NAN, 0.0, 0.0)));
287        q.push(ScriptCommand::ApplyForce(1, Vec3::new(0.0, f32::INFINITY, 0.0)));
288        q.push(ScriptCommand::SetCameraFov(f32::NAN));
289        q.push(ScriptCommand::SetAnimationSpeed(1, f32::NEG_INFINITY));
290        q.push(ScriptCommand::SetRotation(1, Quat::from_xyzw(f32::NAN, 0.0, 0.0, 1.0)));
291
292        assert_eq!(q.rejected_count(), 5, "every one of these should have been refused");
293        assert!(q.drain().is_empty(), "a non-finite command reached the queue");
294    }
295
296    /// …and the guard must not cost the ordinary case anything.
297    #[test]
298    fn finite_commands_pass_through_untouched() {
299        let q = CommandQueue::new();
300        q.push(ScriptCommand::SetPosition(1, Vec3::new(1.0, 2.0, 3.0)));
301        q.push(ScriptCommand::DestroyEntity(2));
302        q.push(ScriptCommand::PlaySound("hit".into()));
303        q.push(ScriptCommand::AddCheckpoint {
304            id: 3,
305            position: Vec3::ZERO,
306            radius: 4.0,
307        });
308
309        assert_eq!(q.rejected_count(), 0);
310        assert_eq!(q.drain().len(), 4);
311    }
312
313    /// A NaN in ONE field is enough, whichever field it is — the multi-float variants are where a
314    /// per-field check gets written for two of three and then forgotten.
315    #[test]
316    fn one_bad_field_condemns_the_whole_command() {
317        let bad_z = ScriptCommand::AddBoxCollider { id: 1, hx: 1.0, hy: 1.0, hz: f32::NAN };
318        assert!(!bad_z.is_finite(), "hz was not checked");
319        let bad_distance =
320            ScriptCommand::SetFightCamera { p1_id: 1, p2_id: 2, height: 3.0, distance: f32::NAN };
321        assert!(!bad_distance.is_finite(), "distance was not checked");
322        let bad_position = ScriptCommand::SpawnPrefab {
323            name: "x".into(),
324            prefab_type: "y".into(),
325            position: Vec3::new(0.0, 0.0, f32::INFINITY),
326        };
327        assert!(!bad_position.is_finite(), "position.z was not checked");
328    }
329    use super::*;
330    use std::sync::Arc;
331
332    /// `drain` FIFO sırayı korumalı ve push edilen HER komutu döndürmeli.
333    #[test]
334    fn drain_preserves_push_order() {
335        let q = CommandQueue::new();
336        q.push(ScriptCommand::SetPosition(1, Vec3::new(1.0, 0.0, 0.0)));
337        q.push(ScriptCommand::DestroyEntity(2));
338        q.push(ScriptCommand::StartRace);
339
340        let drained = q.drain();
341        assert_eq!(drained.len(), 3);
342        assert!(matches!(drained[0], ScriptCommand::SetPosition(1, _)));
343        assert!(matches!(drained[1], ScriptCommand::DestroyEntity(2)));
344        assert!(matches!(drained[2], ScriptCommand::StartRace));
345    }
346
347    /// `new()` ve `default()` her ikisi de boş kuyruk üretmeli; len/is_empty tutarlı olmalı.
348    #[test]
349    fn new_and_default_start_empty_and_agree() {
350        for q in [CommandQueue::new(), CommandQueue::default()] {
351            assert!(q.is_empty());
352            assert_eq!(q.len(), 0);
353        }
354    }
355
356    /// `drain` kuyruğu boşaltmalı: ilk drain komutları döndürür, ikincisi boş döner.
357    #[test]
358    fn drain_empties_queue() {
359        let q = CommandQueue::new();
360        q.push(ScriptCommand::HideDialogue);
361        assert_eq!(q.len(), 1);
362        assert!(!q.is_empty());
363
364        let first = q.drain();
365        assert_eq!(first.len(), 1);
366
367        // Boşaldı: len/is_empty tutarlı, ikinci drain boş.
368        assert_eq!(q.len(), 0);
369        assert!(q.is_empty());
370        assert!(q.drain().is_empty());
371    }
372
373    /// Eşzamanlı push'lar: N thread × M komut = tam olarak N*M komut kaybolmadan birikmeli.
374    /// (Mutex'in sağladığı toplam-koruma invariant'ı.)
375    #[test]
376    fn concurrent_pushes_are_all_recorded() {
377        let q = Arc::new(CommandQueue::new());
378        let threads = 8;
379        let per_thread = 250;
380
381        let handles: Vec<_> = (0..threads)
382            .map(|_| {
383                let q = q.clone();
384                std::thread::spawn(move || {
385                    for i in 0..per_thread {
386                        q.push(ScriptCommand::DestroyEntity(i));
387                    }
388                })
389            })
390            .collect();
391        for h in handles {
392            h.join().unwrap();
393        }
394
395        assert_eq!(q.len(), threads * per_thread as usize);
396        assert_eq!(q.drain().len(), threads * per_thread as usize);
397    }
398
399    /// Zehirlenmiş mutex (bir thread lock tutarken panic etti) kuyruğu kullanılamaz
400    /// bırakmamalı — poison-recovery ile push/drain/len panic-free çalışmaya devam etmeli.
401    #[test]
402    fn survives_poisoned_mutex() {
403        let q = Arc::new(CommandQueue::new());
404        q.push(ScriptCommand::StartRace);
405
406        // Lock'u tutarken panic ederek mutex'i zehirle.
407        let q2 = q.clone();
408        let joined = std::thread::spawn(move || {
409            let _guard = q2.commands.lock().unwrap();
410            panic!("mutex'i kasıtlı zehirle");
411        })
412        .join();
413        assert!(joined.is_err(), "thread panic etmeliydi");
414
415        // Zehirli olsa da kuyruk hâlâ çalışmalı.
416        assert_eq!(q.len(), 1);
417        q.push(ScriptCommand::EndCutscene);
418        assert_eq!(q.len(), 2);
419        let drained = q.drain();
420        assert_eq!(drained.len(), 2);
421        assert!(q.is_empty());
422    }
423}