1use gizmo_math::{Quat, Vec3};
7use std::sync::Mutex;
8#[derive(Debug, Clone)]
10#[non_exhaustive]
11pub enum ScriptCommand {
12 SetPosition(u32, Vec3),
14 SetRotation(u32, Quat),
15 SetScale(u32, Vec3),
16
17 SetVelocity(u32, Vec3),
19 SetAngularVelocity(u32, Vec3),
20
21 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 SetVehicleEngineForce(u32, f32),
42 SetVehicleSteering(u32, f32),
43 SetVehicleBrake(u32, f32),
44
45 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 PlaySound(String),
59 PlaySound3D(String, Vec3),
60 StopSound(String),
61
62 LoadScene(String),
64 SaveScene(String),
65
66 ShowDialogue {
68 speaker: String,
69 text: String,
70 duration: f32,
71 },
72 HideDialogue,
73
74 TriggerCutscene(String), EndCutscene,
77
78 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 SetCameraTarget(u32), SetCameraFov(f32),
94 SetFightCamera {
96 p1_id: u32,
97 p2_id: u32,
98 height: f32, distance: f32, },
101
102SetEntityName(u32, String),
104PlayAnimation {
105 id: u32,
106 name: String,
107 blend: f32,
108 loop_anim: bool,
109 },
110 SetAnimationSpeed(u32, f32),
111
112
113 AddNavAgent(u32),
115 SetAiTarget(u32, Vec3),
116 ClearAiTarget(u32),
117
118 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 #[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 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#[derive(Debug, Default)]
202pub struct CommandQueue {
203 pub commands: Mutex<Vec<ScriptCommand>>,
205 rejected: std::sync::atomic::AtomicU64,
209}
210
211impl CommandQueue {
212 pub fn new() -> Self {
214 Self {
215 commands: Mutex::new(Vec::new()),
216 rejected: std::sync::atomic::AtomicU64::new(0),
217 }
218 }
219
220 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 self.commands
238 .lock()
239 .unwrap_or_else(|e| e.into_inner())
240 .push(cmd);
241 }
242
243 #[must_use]
246 pub fn rejected_count(&self) -> u64 {
247 self.rejected.load(std::sync::atomic::Ordering::Relaxed)
248 }
249
250 pub fn drain(&self) -> Vec<ScriptCommand> {
252 self.commands
254 .lock()
255 .unwrap_or_else(|e| e.into_inner())
256 .drain(..)
257 .collect()
258 }
259
260 pub fn is_empty(&self) -> bool {
262 self.commands
264 .lock()
265 .unwrap_or_else(|e| e.into_inner())
266 .is_empty()
267 }
268
269 pub fn len(&self) -> usize {
271 self.commands
273 .lock()
274 .unwrap_or_else(|e| e.into_inner())
275 .len()
276 }
277}
278
279#[cfg(test)]
280mod tests {
281
282 #[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 #[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 #[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 #[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 #[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 #[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 assert_eq!(q.len(), 0);
369 assert!(q.is_empty());
370 assert!(q.drain().is_empty());
371 }
372
373 #[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 #[test]
402 fn survives_poisoned_mutex() {
403 let q = Arc::new(CommandQueue::new());
404 q.push(ScriptCommand::StartRace);
405
406 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 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}