#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)]
use glam::Vec2;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
pub mod contraption;
pub mod material;
pub mod remix;
pub mod thermometer;
pub use contraption::*;
pub use material::*;
pub use remix::*;
pub use thermometer::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ContraptionId(pub Uuid);
impl ContraptionId {
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
#[must_use]
pub const fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
}
impl Default for ContraptionId {
fn default() -> Self {
Self::new()
}
}
impl core::fmt::Display for ContraptionId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum SandboxError {
#[error("Contraption exceeds object limit: {count} > {limit}")]
ObjectLimitExceeded {
count: usize,
limit: usize,
},
#[error("Invalid material: {reason}")]
InvalidMaterial {
reason: String,
},
#[error("Serialization failed: {0}")]
SerializationError(String),
#[error("Deserialization failed: invalid or corrupt data")]
DeserializationError,
#[error("Engine version mismatch: contraption requires {required}, current is {current}")]
VersionMismatch {
required: String,
current: String,
},
#[error("Contraption not found: {0}")]
NotFound(ContraptionId),
}
pub type Result<T> = core::result::Result<T, SandboxError>;
pub const MAX_OBJECTS_PER_CONTRAPTION: usize = 500;
pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PhysicsBackend {
#[default]
WebGpu,
WasmSimd,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Transform2D {
pub position: Vec2,
pub rotation: f32,
pub scale: Vec2,
}
impl Default for Transform2D {
fn default() -> Self {
Self {
position: Vec2::ZERO,
rotation: 0.0,
scale: Vec2::ONE,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ObjectType {
Ball,
Domino,
Ramp,
Lever,
Pulley,
Spring,
Fan,
Magnet,
Bucket,
Sensor,
}
impl ObjectType {
#[must_use]
pub const fn is_dynamic(&self) -> bool {
matches!(self, Self::Ball | Self::Domino)
}
#[must_use]
pub const fn is_trigger(&self) -> bool {
matches!(self, Self::Bucket | Self::Sensor)
}
#[must_use]
pub const fn is_constraint(&self) -> bool {
matches!(self, Self::Pulley | Self::Spring)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Difficulty {
Easy,
#[default]
Medium,
Hard,
Expert,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
mod contraption_id_tests {
use super::*;
#[test]
fn test_contraption_id_uniqueness() {
let id1 = ContraptionId::new();
let id2 = ContraptionId::new();
assert_ne!(id1, id2, "Each ID should be unique");
}
#[test]
fn test_contraption_id_display() {
let id = ContraptionId::new();
let display = id.to_string();
assert!(!display.is_empty());
assert!(display.contains('-'), "UUID format should contain hyphens");
}
#[test]
fn test_contraption_id_from_uuid() {
let uuid = Uuid::new_v4();
let id = ContraptionId::from_uuid(uuid);
assert_eq!(id.0, uuid);
}
}
mod object_type_tests {
use super::*;
#[test]
fn test_ball_is_dynamic() {
assert!(ObjectType::Ball.is_dynamic());
}
#[test]
fn test_domino_is_dynamic() {
assert!(ObjectType::Domino.is_dynamic());
}
#[test]
fn test_ramp_is_not_dynamic() {
assert!(!ObjectType::Ramp.is_dynamic());
}
#[test]
fn test_bucket_is_trigger() {
assert!(ObjectType::Bucket.is_trigger());
}
#[test]
fn test_sensor_is_trigger() {
assert!(ObjectType::Sensor.is_trigger());
}
#[test]
fn test_ball_is_not_trigger() {
assert!(!ObjectType::Ball.is_trigger());
}
#[test]
fn test_spring_is_constraint() {
assert!(ObjectType::Spring.is_constraint());
}
#[test]
fn test_pulley_is_constraint() {
assert!(ObjectType::Pulley.is_constraint());
}
#[test]
fn test_ball_is_not_constraint() {
assert!(!ObjectType::Ball.is_constraint());
}
}
mod transform_tests {
use super::*;
#[test]
fn test_transform_default() {
let t = Transform2D::default();
assert_eq!(t.position, Vec2::ZERO);
assert!((t.rotation - 0.0).abs() < f32::EPSILON);
assert_eq!(t.scale, Vec2::ONE);
}
#[test]
fn test_transform_serialization() {
let t = Transform2D {
position: Vec2::new(100.0, 200.0),
rotation: 1.5,
scale: Vec2::new(2.0, 3.0),
};
let json = serde_json::to_string(&t).unwrap();
let restored: Transform2D = serde_json::from_str(&json).unwrap();
assert_eq!(t, restored);
}
}
mod physics_backend_tests {
use super::*;
#[test]
fn test_default_backend_is_webgpu() {
assert_eq!(PhysicsBackend::default(), PhysicsBackend::WebGpu);
}
#[test]
fn test_backend_serialization() {
let backend = PhysicsBackend::WasmSimd;
let json = serde_json::to_string(&backend).unwrap();
let restored: PhysicsBackend = serde_json::from_str(&json).unwrap();
assert_eq!(backend, restored);
}
}
mod error_tests {
use super::*;
#[test]
fn test_object_limit_error_display() {
let err = SandboxError::ObjectLimitExceeded {
count: 600,
limit: 500,
};
let msg = err.to_string();
assert!(msg.contains("600"));
assert!(msg.contains("500"));
}
#[test]
fn test_version_mismatch_error() {
let err = SandboxError::VersionMismatch {
required: "1.0.0".to_string(),
current: "0.1.0".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("1.0.0"));
assert!(msg.contains("0.1.0"));
}
#[test]
fn test_deserialization_error() {
let err = SandboxError::DeserializationError;
let msg = err.to_string();
assert!(msg.contains("invalid") || msg.contains("corrupt"));
}
}
mod difficulty_tests {
use super::*;
#[test]
fn test_default_difficulty_is_medium() {
assert_eq!(Difficulty::default(), Difficulty::Medium);
}
}
}