1#![forbid(unsafe_code)]
30#![warn(missing_docs)]
31#![warn(clippy::pedantic)]
32#![allow(clippy::module_name_repetitions)]
33
34use glam::Vec2;
35use serde::{Deserialize, Serialize};
36use thiserror::Error;
37use uuid::Uuid;
38
39pub mod contraption;
40pub mod material;
41pub mod remix;
42pub mod thermometer;
43
44pub use contraption::*;
45pub use material::*;
46pub use remix::*;
47pub use thermometer::*;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub struct ContraptionId(pub Uuid);
52
53impl ContraptionId {
54 #[must_use]
56 pub fn new() -> Self {
57 Self(Uuid::new_v4())
58 }
59
60 #[must_use]
62 pub const fn from_uuid(uuid: Uuid) -> Self {
63 Self(uuid)
64 }
65}
66
67impl Default for ContraptionId {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl core::fmt::Display for ContraptionId {
74 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
75 write!(f, "{}", self.0)
76 }
77}
78
79#[derive(Error, Debug, Clone, PartialEq, Eq)]
81pub enum SandboxError {
82 #[error("Contraption exceeds object limit: {count} > {limit}")]
84 ObjectLimitExceeded {
85 count: usize,
87 limit: usize,
89 },
90
91 #[error("Invalid material: {reason}")]
93 InvalidMaterial {
94 reason: String,
96 },
97
98 #[error("Serialization failed: {0}")]
100 SerializationError(String),
101
102 #[error("Deserialization failed: invalid or corrupt data")]
104 DeserializationError,
105
106 #[error("Engine version mismatch: contraption requires {required}, current is {current}")]
108 VersionMismatch {
109 required: String,
111 current: String,
113 },
114
115 #[error("Contraption not found: {0}")]
117 NotFound(ContraptionId),
118}
119
120pub type Result<T> = core::result::Result<T, SandboxError>;
122
123pub const MAX_OBJECTS_PER_CONTRAPTION: usize = 500;
125
126pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
131pub enum PhysicsBackend {
132 #[default]
134 WebGpu,
135 WasmSimd,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
141pub struct Transform2D {
142 pub position: Vec2,
144 pub rotation: f32,
146 pub scale: Vec2,
148}
149
150impl Default for Transform2D {
151 fn default() -> Self {
152 Self {
153 position: Vec2::ZERO,
154 rotation: 0.0,
155 scale: Vec2::ONE,
156 }
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
162pub enum ObjectType {
163 Ball,
165 Domino,
167 Ramp,
169 Lever,
171 Pulley,
173 Spring,
175 Fan,
177 Magnet,
179 Bucket,
181 Sensor,
183}
184
185impl ObjectType {
186 #[must_use]
188 pub const fn is_dynamic(&self) -> bool {
189 matches!(self, Self::Ball | Self::Domino)
190 }
191
192 #[must_use]
194 pub const fn is_trigger(&self) -> bool {
195 matches!(self, Self::Bucket | Self::Sensor)
196 }
197
198 #[must_use]
200 pub const fn is_constraint(&self) -> bool {
201 matches!(self, Self::Pulley | Self::Spring)
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
207pub enum Difficulty {
208 Easy,
210 #[default]
212 Medium,
213 Hard,
215 Expert,
217}
218
219#[cfg(test)]
220#[allow(clippy::unwrap_used, clippy::expect_used)]
221mod tests {
222 use super::*;
223
224 mod contraption_id_tests {
229 use super::*;
230
231 #[test]
232 fn test_contraption_id_uniqueness() {
233 let id1 = ContraptionId::new();
234 let id2 = ContraptionId::new();
235 assert_ne!(id1, id2, "Each ID should be unique");
236 }
237
238 #[test]
239 fn test_contraption_id_display() {
240 let id = ContraptionId::new();
241 let display = id.to_string();
242 assert!(!display.is_empty());
243 assert!(display.contains('-'), "UUID format should contain hyphens");
244 }
245
246 #[test]
247 fn test_contraption_id_from_uuid() {
248 let uuid = Uuid::new_v4();
249 let id = ContraptionId::from_uuid(uuid);
250 assert_eq!(id.0, uuid);
251 }
252 }
253
254 mod object_type_tests {
255 use super::*;
256
257 #[test]
258 fn test_ball_is_dynamic() {
259 assert!(ObjectType::Ball.is_dynamic());
260 }
261
262 #[test]
263 fn test_domino_is_dynamic() {
264 assert!(ObjectType::Domino.is_dynamic());
265 }
266
267 #[test]
268 fn test_ramp_is_not_dynamic() {
269 assert!(!ObjectType::Ramp.is_dynamic());
270 }
271
272 #[test]
273 fn test_bucket_is_trigger() {
274 assert!(ObjectType::Bucket.is_trigger());
275 }
276
277 #[test]
278 fn test_sensor_is_trigger() {
279 assert!(ObjectType::Sensor.is_trigger());
280 }
281
282 #[test]
283 fn test_ball_is_not_trigger() {
284 assert!(!ObjectType::Ball.is_trigger());
285 }
286
287 #[test]
288 fn test_spring_is_constraint() {
289 assert!(ObjectType::Spring.is_constraint());
290 }
291
292 #[test]
293 fn test_pulley_is_constraint() {
294 assert!(ObjectType::Pulley.is_constraint());
295 }
296
297 #[test]
298 fn test_ball_is_not_constraint() {
299 assert!(!ObjectType::Ball.is_constraint());
300 }
301 }
302
303 mod transform_tests {
304 use super::*;
305
306 #[test]
307 fn test_transform_default() {
308 let t = Transform2D::default();
309 assert_eq!(t.position, Vec2::ZERO);
310 assert!((t.rotation - 0.0).abs() < f32::EPSILON);
311 assert_eq!(t.scale, Vec2::ONE);
312 }
313
314 #[test]
315 fn test_transform_serialization() {
316 let t = Transform2D {
317 position: Vec2::new(100.0, 200.0),
318 rotation: 1.5,
319 scale: Vec2::new(2.0, 3.0),
320 };
321 let json = serde_json::to_string(&t).unwrap();
322 let restored: Transform2D = serde_json::from_str(&json).unwrap();
323 assert_eq!(t, restored);
324 }
325 }
326
327 mod physics_backend_tests {
328 use super::*;
329
330 #[test]
331 fn test_default_backend_is_webgpu() {
332 assert_eq!(PhysicsBackend::default(), PhysicsBackend::WebGpu);
333 }
334
335 #[test]
336 fn test_backend_serialization() {
337 let backend = PhysicsBackend::WasmSimd;
338 let json = serde_json::to_string(&backend).unwrap();
339 let restored: PhysicsBackend = serde_json::from_str(&json).unwrap();
340 assert_eq!(backend, restored);
341 }
342 }
343
344 mod error_tests {
345 use super::*;
346
347 #[test]
348 fn test_object_limit_error_display() {
349 let err = SandboxError::ObjectLimitExceeded {
350 count: 600,
351 limit: 500,
352 };
353 let msg = err.to_string();
354 assert!(msg.contains("600"));
355 assert!(msg.contains("500"));
356 }
357
358 #[test]
359 fn test_version_mismatch_error() {
360 let err = SandboxError::VersionMismatch {
361 required: "1.0.0".to_string(),
362 current: "0.1.0".to_string(),
363 };
364 let msg = err.to_string();
365 assert!(msg.contains("1.0.0"));
366 assert!(msg.contains("0.1.0"));
367 }
368
369 #[test]
370 fn test_deserialization_error() {
371 let err = SandboxError::DeserializationError;
372 let msg = err.to_string();
373 assert!(msg.contains("invalid") || msg.contains("corrupt"));
374 }
375 }
376
377 mod difficulty_tests {
378 use super::*;
379
380 #[test]
381 fn test_default_difficulty_is_medium() {
382 assert_eq!(Difficulty::default(), Difficulty::Medium);
383 }
384 }
385}