//! Database models for chattyness. //! //! These structs mirror the database schema and are used for SQLx queries. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; #[cfg(feature = "ssr")] use chattyness_error::AppError; #[cfg(feature = "ssr")] use chattyness_shared::validation; // ============================================================================= // Enums (matching PostgreSQL ENUMs) // ============================================================================= /// Realm privacy setting. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "realm_privacy", rename_all = "lowercase") )] #[serde(rename_all = "lowercase")] pub enum RealmPrivacy { #[default] Public, Unlisted, Private, } impl std::fmt::Display for RealmPrivacy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { RealmPrivacy::Public => write!(f, "public"), RealmPrivacy::Unlisted => write!(f, "unlisted"), RealmPrivacy::Private => write!(f, "private"), } } } impl std::str::FromStr for RealmPrivacy { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "public" => Ok(RealmPrivacy::Public), "unlisted" => Ok(RealmPrivacy::Unlisted), "private" => Ok(RealmPrivacy::Private), _ => Err(format!("Invalid privacy setting: {}", s)), } } } impl RealmPrivacy { /// Get the string representation for database storage. pub fn as_str(&self) -> &'static str { match self { RealmPrivacy::Public => "public", RealmPrivacy::Unlisted => "unlisted", RealmPrivacy::Private => "private", } } } /// Server-wide reputation tier. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "reputation_tier", rename_all = "lowercase") )] #[serde(rename_all = "lowercase")] pub enum ReputationTier { Guest, #[default] Member, Established, Trusted, Elder, } /// User account status. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "account_status", rename_all = "lowercase") )] #[serde(rename_all = "lowercase")] pub enum AccountStatus { #[default] Active, Suspended, Banned, Deleted, } /// User account tag for feature gating and access control. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "user_tag", rename_all = "snake_case") )] #[serde(rename_all = "snake_case")] pub enum UserTag { Guest, Unvalidated, ValidatedEmail, ValidatedSocial, ValidatedOauth2, Premium, } /// Authentication provider. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "auth_provider", rename_all = "snake_case") )] #[serde(rename_all = "snake_case")] pub enum AuthProvider { #[default] Local, OauthGoogle, OauthDiscord, OauthGithub, } /// Server-level staff role. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "server_role", rename_all = "lowercase") )] #[serde(rename_all = "lowercase")] pub enum ServerRole { #[default] Moderator, Admin, Owner, } impl std::fmt::Display for ServerRole { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ServerRole::Moderator => write!(f, "moderator"), ServerRole::Admin => write!(f, "admin"), ServerRole::Owner => write!(f, "owner"), } } } impl std::str::FromStr for ServerRole { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "moderator" => Ok(ServerRole::Moderator), "admin" => Ok(ServerRole::Admin), "owner" => Ok(ServerRole::Owner), _ => Err(format!("Invalid server role: {}", s)), } } } /// Realm membership role. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "realm_role", rename_all = "lowercase") )] #[serde(rename_all = "lowercase")] pub enum RealmRole { #[default] Member, Builder, Moderator, Owner, } impl std::fmt::Display for RealmRole { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { RealmRole::Member => write!(f, "member"), RealmRole::Builder => write!(f, "builder"), RealmRole::Moderator => write!(f, "moderator"), RealmRole::Owner => write!(f, "owner"), } } } impl std::str::FromStr for RealmRole { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "member" => Ok(RealmRole::Member), "builder" => Ok(RealmRole::Builder), "moderator" => Ok(RealmRole::Moderator), "owner" => Ok(RealmRole::Owner), _ => Err(format!("Invalid realm role: {}", s)), } } } /// Scene dimension mode. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "dimension_mode", rename_all = "lowercase") )] #[serde(rename_all = "lowercase")] pub enum DimensionMode { #[default] Fixed, Viewport, } impl std::fmt::Display for DimensionMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DimensionMode::Fixed => write!(f, "fixed"), DimensionMode::Viewport => write!(f, "viewport"), } } } impl std::str::FromStr for DimensionMode { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "fixed" => Ok(DimensionMode::Fixed), "viewport" => Ok(DimensionMode::Viewport), _ => Err(format!("Invalid dimension mode: {}", s)), } } } /// Interactive spot type. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "spot_type", rename_all = "lowercase") )] #[serde(rename_all = "lowercase")] pub enum SpotType { #[default] Normal, Door, Trigger, } impl std::fmt::Display for SpotType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { SpotType::Normal => write!(f, "normal"), SpotType::Door => write!(f, "door"), SpotType::Trigger => write!(f, "trigger"), } } } impl std::str::FromStr for SpotType { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "normal" => Ok(SpotType::Normal), "door" => Ok(SpotType::Door), "trigger" => Ok(SpotType::Trigger), _ => Err(format!("Invalid spot type: {}", s)), } } } /// Avatar layer for prop positioning (z-depth). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "avatar_layer", rename_all = "lowercase") )] #[serde(rename_all = "lowercase")] pub enum AvatarLayer { Skin, #[default] Clothes, Accessories, } impl std::fmt::Display for AvatarLayer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AvatarLayer::Skin => write!(f, "skin"), AvatarLayer::Clothes => write!(f, "clothes"), AvatarLayer::Accessories => write!(f, "accessories"), } } } impl std::str::FromStr for AvatarLayer { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "skin" => Ok(AvatarLayer::Skin), "clothes" => Ok(AvatarLayer::Clothes), "accessories" => Ok(AvatarLayer::Accessories), _ => Err(format!("Invalid avatar layer: {}", s)), } } } /// Emotion state for avatar emotion overlays. /// /// Maps to emotion slots 0-11 in the avatar grid: /// - e0: neutral, e1: happy, e2: sad, e3: angry, e4: surprised /// - e5: thinking, e6: laughing, e7: crying, e8: love, e9: confused /// - e10: sleeping, e11: wink #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "emotion_state", rename_all = "lowercase") )] #[serde(rename_all = "lowercase")] pub enum EmotionState { #[default] Neutral, Happy, Sad, Angry, Surprised, Thinking, Laughing, Crying, Love, Confused, Sleeping, Wink, } impl std::fmt::Display for EmotionState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { EmotionState::Neutral => write!(f, "neutral"), EmotionState::Happy => write!(f, "happy"), EmotionState::Sad => write!(f, "sad"), EmotionState::Angry => write!(f, "angry"), EmotionState::Surprised => write!(f, "surprised"), EmotionState::Thinking => write!(f, "thinking"), EmotionState::Laughing => write!(f, "laughing"), EmotionState::Crying => write!(f, "crying"), EmotionState::Love => write!(f, "love"), EmotionState::Confused => write!(f, "confused"), EmotionState::Sleeping => write!(f, "sleeping"), EmotionState::Wink => write!(f, "wink"), } } } impl std::str::FromStr for EmotionState { type Err = String; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "neutral" => Ok(EmotionState::Neutral), "happy" => Ok(EmotionState::Happy), "sad" => Ok(EmotionState::Sad), "angry" => Ok(EmotionState::Angry), "surprised" => Ok(EmotionState::Surprised), "thinking" => Ok(EmotionState::Thinking), "laughing" => Ok(EmotionState::Laughing), "crying" => Ok(EmotionState::Crying), "love" => Ok(EmotionState::Love), "confused" => Ok(EmotionState::Confused), "sleeping" => Ok(EmotionState::Sleeping), "wink" => Ok(EmotionState::Wink), _ => Err(format!("Invalid emotion state: {}", s)), } } } impl EmotionState { /// Convert a keybinding index (0-11) to an emotion state. pub fn from_index(i: u8) -> Option { match i { 0 => Some(Self::Neutral), 1 => Some(Self::Happy), 2 => Some(Self::Sad), 3 => Some(Self::Angry), 4 => Some(Self::Surprised), 5 => Some(Self::Thinking), 6 => Some(Self::Laughing), 7 => Some(Self::Crying), 8 => Some(Self::Love), 9 => Some(Self::Confused), 10 => Some(Self::Sleeping), 11 => Some(Self::Wink), _ => None, } } /// Convert emotion state to its index (0-11). pub fn to_index(&self) -> u8 { match self { Self::Neutral => 0, Self::Happy => 1, Self::Sad => 2, Self::Angry => 3, Self::Surprised => 4, Self::Thinking => 5, Self::Laughing => 6, Self::Crying => 7, Self::Love => 8, Self::Confused => 9, Self::Sleeping => 10, Self::Wink => 11, } } } // ============================================================================= // User Models // ============================================================================= /// A user account. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct User { pub id: Uuid, pub username: String, pub email: Option, pub display_name: String, pub bio: Option, pub avatar_url: Option, pub reputation_tier: ReputationTier, pub status: AccountStatus, pub email_verified: bool, pub tags: Vec, pub created_at: DateTime, pub updated_at: DateTime, } impl User { /// Check if this user is a guest (has the Guest tag). pub fn is_guest(&self) -> bool { self.tags.contains(&UserTag::Guest) } } /// Minimal user info for display purposes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserSummary { pub id: Uuid, pub username: String, pub display_name: String, pub avatar_url: Option, } // ============================================================================= // Realm Models // ============================================================================= /// A realm (themed virtual space). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Realm { pub id: Uuid, pub name: String, pub slug: String, pub description: Option, pub tagline: Option, pub owner_id: Uuid, pub privacy: RealmPrivacy, pub is_nsfw: bool, pub min_reputation_tier: ReputationTier, pub theme_color: Option, pub banner_image_path: Option, pub thumbnail_path: Option, pub max_users: i32, pub allow_guest_access: bool, pub allow_user_teleport: bool, pub default_scene_id: Option, pub member_count: i32, pub current_user_count: i32, pub created_at: DateTime, pub updated_at: DateTime, } /// Realm with the current user's role (if authenticated and a member). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RealmWithUserRole { #[serde(flatten)] pub realm: Realm, /// The current user's role in this realm, if they are a member. pub user_role: Option, } /// Request to create a new realm. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateRealmRequest { pub name: String, pub slug: String, pub description: Option, pub tagline: Option, pub privacy: RealmPrivacy, pub is_nsfw: bool, pub max_users: i32, pub allow_guest_access: bool, pub allow_user_teleport: bool, pub theme_color: Option, } #[cfg(feature = "ssr")] impl CreateRealmRequest { /// Validate the create realm request. pub fn validate(&self) -> Result<(), AppError> { validation::validate_non_empty(&self.name, "Realm name")?; validation::validate_slug(&self.slug)?; validation::validate_range(self.max_users, "Max users", 1, 10000)?; validation::validate_optional_hex_color(self.theme_color.as_deref())?; Ok(()) } } /// Minimal realm info for listings. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct RealmSummary { pub id: Uuid, pub name: String, pub slug: String, pub tagline: Option, pub privacy: RealmPrivacy, pub is_nsfw: bool, pub thumbnail_path: Option, pub member_count: i32, pub current_user_count: i32, } /// Response after creating a realm. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateRealmResponse { pub id: Uuid, pub slug: String, pub redirect_url: String, } // ============================================================================= // Scene Models // ============================================================================= /// A scene (room within a realm). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Scene { pub id: Uuid, pub realm_id: Uuid, pub name: String, pub slug: String, pub description: Option, pub background_image_path: Option, pub background_color: Option, /// Bounds as WKT string (e.g., "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))") pub bounds_wkt: String, pub dimension_mode: DimensionMode, pub ambient_audio_id: Option, pub ambient_volume: Option, pub sort_order: i32, pub is_entry_point: bool, pub is_hidden: bool, pub created_at: DateTime, pub updated_at: DateTime, /// Default public channel ID for this scene. pub default_channel_id: Option, } /// Minimal scene info for listings. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct SceneSummary { pub id: Uuid, pub name: String, pub slug: String, pub sort_order: i32, pub is_entry_point: bool, pub is_hidden: bool, pub background_color: Option, pub background_image_path: Option, } /// A spot (interactive region within a scene). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Spot { pub id: Uuid, pub scene_id: Uuid, pub name: Option, pub slug: Option, /// Region as WKT string (e.g., "POLYGON((100 100, 200 100, 200 200, 100 200, 100 100))") pub region_wkt: String, pub spot_type: SpotType, pub destination_scene_id: Option, /// Destination position as WKT string (e.g., "POINT(400 300)") pub destination_position_wkt: Option, pub current_state: i16, pub sort_order: i32, pub is_visible: bool, pub is_active: bool, pub created_at: DateTime, pub updated_at: DateTime, } /// Minimal spot info for listings. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct SpotSummary { pub id: Uuid, pub name: Option, pub slug: Option, pub spot_type: SpotType, pub region_wkt: String, pub sort_order: i32, pub is_visible: bool, pub is_active: bool, } // ============================================================================= // Props Models // ============================================================================= /// Origin source for a prop in inventory. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] #[cfg_attr( feature = "ssr", sqlx(type_name = "prop_origin", rename_all = "snake_case") )] #[serde(rename_all = "snake_case")] pub enum PropOrigin { #[default] ServerLibrary, RealmLibrary, UserUpload, } impl std::fmt::Display for PropOrigin { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { PropOrigin::ServerLibrary => write!(f, "server_library"), PropOrigin::RealmLibrary => write!(f, "realm_library"), PropOrigin::UserUpload => write!(f, "user_upload"), } } } /// An inventory item (user-owned prop). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct InventoryItem { pub id: Uuid, pub prop_name: String, pub prop_asset_path: String, pub layer: Option, pub is_transferable: bool, pub is_portable: bool, pub is_droppable: bool, pub origin: PropOrigin, pub acquired_at: DateTime, } /// Response for inventory list. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InventoryResponse { pub items: Vec, } /// A public prop from server or realm library. /// Used for the public inventory tabs (Server/Realm). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct PublicProp { pub id: Uuid, pub name: String, pub asset_path: String, pub description: Option, } /// Response for public props list. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PublicPropsResponse { pub props: Vec, } /// A prop dropped in a channel, available for pickup. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct LooseProp { pub id: Uuid, pub channel_id: Uuid, pub server_prop_id: Option, pub realm_prop_id: Option, pub position_x: f64, pub position_y: f64, pub dropped_by: Option, pub expires_at: Option>, pub created_at: DateTime, /// Prop name (JOINed from source prop). pub prop_name: String, /// Asset path for rendering (JOINed from source prop). pub prop_asset_path: String, } /// A server-wide prop (global library). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ServerProp { pub id: Uuid, pub name: String, pub slug: String, pub description: Option, pub tags: Vec, pub asset_path: String, pub thumbnail_path: Option, /// Default content layer (skin/clothes/accessories). Mutually exclusive with default_emotion. pub default_layer: Option, /// Default emotion layer (neutral/happy/sad/etc). Mutually exclusive with default_layer. pub default_emotion: Option, /// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8 pub default_position: Option, pub is_unique: bool, pub is_transferable: bool, pub is_portable: bool, pub is_droppable: bool, pub is_public: bool, pub is_active: bool, pub available_from: Option>, pub available_until: Option>, pub created_by: Option, pub created_at: DateTime, pub updated_at: DateTime, } /// Minimal server prop info for listings. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ServerPropSummary { pub id: Uuid, pub name: String, pub slug: String, pub asset_path: String, pub default_layer: Option, pub is_active: bool, pub created_at: DateTime, } /// Request to create a server prop. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateServerPropRequest { pub name: String, #[serde(default)] pub slug: Option, #[serde(default)] pub description: Option, #[serde(default)] pub tags: Vec, /// Default content layer (skin/clothes/accessories). Mutually exclusive with default_emotion. #[serde(default)] pub default_layer: Option, /// Default emotion layer (neutral/happy/sad/etc). Mutually exclusive with default_layer. #[serde(default)] pub default_emotion: Option, /// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8 #[serde(default)] pub default_position: Option, /// Whether prop is droppable (can be dropped in a channel). #[serde(default)] pub droppable: Option, /// Whether prop appears in the public Server inventory tab. #[serde(default)] pub public: Option, } #[cfg(feature = "ssr")] impl CreateServerPropRequest { /// Validate the create prop request. pub fn validate(&self) -> Result<(), AppError> { validation::validate_non_empty(&self.name, "Prop name")?; if let Some(ref slug) = self.slug { validation::validate_slug(slug)?; } // Validate grid position is 0-8 if let Some(pos) = self.default_position { if !(0..=8).contains(&pos) { return Err(AppError::Validation( "default_position must be between 0 and 8".to_string(), )); } } // Validate mutual exclusivity: can't have both default_layer and default_emotion if self.default_layer.is_some() && self.default_emotion.is_some() { return Err(AppError::Validation( "Cannot specify both default_layer and default_emotion - they are mutually exclusive".to_string(), )); } // If either layer or emotion is set, position must also be set if (self.default_layer.is_some() || self.default_emotion.is_some()) && self.default_position.is_none() { return Err(AppError::Validation( "default_position is required when default_layer or default_emotion is set" .to_string(), )); } Ok(()) } /// Generate a slug from the name if not provided. pub fn slug_or_generate(&self) -> String { self.slug.clone().unwrap_or_else(|| { self.name .to_lowercase() .chars() .map(|c| if c.is_alphanumeric() { c } else { '-' }) .collect::() .trim_matches('-') .to_string() }) } } /// A saved avatar configuration (up to 10 per user). /// /// Contains 117 prop slot references: /// - 27 content layer slots (3 layers × 9 positions) /// - 90 emotion layer slots (10 emotions × 9 positions) /// /// Grid positions (0-8): /// ```text /// ┌───┬───┬───┐ /// │ 0 │ 1 │ 2 │ top row /// ├───┼───┼───┤ /// │ 3 │ 4 │ 5 │ middle row /// ├───┼───┼───┤ /// │ 6 │ 7 │ 8 │ bottom row /// └───┴───┴───┘ /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Avatar { pub id: Uuid, pub user_id: Uuid, pub name: String, /// Slot number (0-9, keyboard: a0-a9) pub slot_number: i16, /// Last used emotion slot (0-9, NULL if none) pub last_emotion: Option, // Content Layer: Skin (behind user, body/face) pub l_skin_0: Option, pub l_skin_1: Option, pub l_skin_2: Option, pub l_skin_3: Option, pub l_skin_4: Option, pub l_skin_5: Option, pub l_skin_6: Option, pub l_skin_7: Option, pub l_skin_8: Option, // Content Layer: Clothes (with user, worn items) pub l_clothes_0: Option, pub l_clothes_1: Option, pub l_clothes_2: Option, pub l_clothes_3: Option, pub l_clothes_4: Option, pub l_clothes_5: Option, pub l_clothes_6: Option, pub l_clothes_7: Option, pub l_clothes_8: Option, // Content Layer: Accessories (in front of user, held/attached items) pub l_accessories_0: Option, pub l_accessories_1: Option, pub l_accessories_2: Option, pub l_accessories_3: Option, pub l_accessories_4: Option, pub l_accessories_5: Option, pub l_accessories_6: Option, pub l_accessories_7: Option, pub l_accessories_8: Option, // Emotion: Neutral (e0) pub e_neutral_0: Option, pub e_neutral_1: Option, pub e_neutral_2: Option, pub e_neutral_3: Option, pub e_neutral_4: Option, pub e_neutral_5: Option, pub e_neutral_6: Option, pub e_neutral_7: Option, pub e_neutral_8: Option, // Emotion: Happy (e1) pub e_happy_0: Option, pub e_happy_1: Option, pub e_happy_2: Option, pub e_happy_3: Option, pub e_happy_4: Option, pub e_happy_5: Option, pub e_happy_6: Option, pub e_happy_7: Option, pub e_happy_8: Option, // Emotion: Sad (e2) pub e_sad_0: Option, pub e_sad_1: Option, pub e_sad_2: Option, pub e_sad_3: Option, pub e_sad_4: Option, pub e_sad_5: Option, pub e_sad_6: Option, pub e_sad_7: Option, pub e_sad_8: Option, // Emotion: Angry (e3) pub e_angry_0: Option, pub e_angry_1: Option, pub e_angry_2: Option, pub e_angry_3: Option, pub e_angry_4: Option, pub e_angry_5: Option, pub e_angry_6: Option, pub e_angry_7: Option, pub e_angry_8: Option, // Emotion: Surprised (e4) pub e_surprised_0: Option, pub e_surprised_1: Option, pub e_surprised_2: Option, pub e_surprised_3: Option, pub e_surprised_4: Option, pub e_surprised_5: Option, pub e_surprised_6: Option, pub e_surprised_7: Option, pub e_surprised_8: Option, // Emotion: Thinking (e5) pub e_thinking_0: Option, pub e_thinking_1: Option, pub e_thinking_2: Option, pub e_thinking_3: Option, pub e_thinking_4: Option, pub e_thinking_5: Option, pub e_thinking_6: Option, pub e_thinking_7: Option, pub e_thinking_8: Option, // Emotion: Laughing (e6) pub e_laughing_0: Option, pub e_laughing_1: Option, pub e_laughing_2: Option, pub e_laughing_3: Option, pub e_laughing_4: Option, pub e_laughing_5: Option, pub e_laughing_6: Option, pub e_laughing_7: Option, pub e_laughing_8: Option, // Emotion: Crying (e7) pub e_crying_0: Option, pub e_crying_1: Option, pub e_crying_2: Option, pub e_crying_3: Option, pub e_crying_4: Option, pub e_crying_5: Option, pub e_crying_6: Option, pub e_crying_7: Option, pub e_crying_8: Option, // Emotion: Love (e8) pub e_love_0: Option, pub e_love_1: Option, pub e_love_2: Option, pub e_love_3: Option, pub e_love_4: Option, pub e_love_5: Option, pub e_love_6: Option, pub e_love_7: Option, pub e_love_8: Option, // Emotion: Confused (e9) pub e_confused_0: Option, pub e_confused_1: Option, pub e_confused_2: Option, pub e_confused_3: Option, pub e_confused_4: Option, pub e_confused_5: Option, pub e_confused_6: Option, pub e_confused_7: Option, pub e_confused_8: Option, // Emotion: Sleeping (e10) pub e_sleeping_0: Option, pub e_sleeping_1: Option, pub e_sleeping_2: Option, pub e_sleeping_3: Option, pub e_sleeping_4: Option, pub e_sleeping_5: Option, pub e_sleeping_6: Option, pub e_sleeping_7: Option, pub e_sleeping_8: Option, // Emotion: Wink (e11) pub e_wink_0: Option, pub e_wink_1: Option, pub e_wink_2: Option, pub e_wink_3: Option, pub e_wink_4: Option, pub e_wink_5: Option, pub e_wink_6: Option, pub e_wink_7: Option, pub e_wink_8: Option, pub created_at: DateTime, pub updated_at: DateTime, } /// Currently active avatar for a user in a realm. /// Users can have different active avatars per realm. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ActiveAvatar { pub user_id: Uuid, pub realm_id: Uuid, pub avatar_id: Uuid, /// Current emotion slot (0-9, keyboard: e0-e9) pub current_emotion: i16, pub updated_at: DateTime, } // ============================================================================= // Server Config Models // ============================================================================= /// Server-wide configuration (singleton). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ServerConfig { pub id: Uuid, pub name: String, pub description: Option, pub welcome_message: Option, pub max_users_per_channel: i32, pub message_rate_limit: i32, pub message_rate_window_seconds: i32, pub allow_guest_access: bool, pub allow_user_uploads: bool, pub require_email_verification: bool, pub created_at: DateTime, pub updated_at: DateTime, } /// Request to update server configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateServerConfigRequest { pub name: String, pub description: Option, pub welcome_message: Option, pub max_users_per_channel: i32, pub message_rate_limit: i32, pub message_rate_window_seconds: i32, pub allow_guest_access: bool, pub allow_user_uploads: bool, pub require_email_verification: bool, } #[cfg(feature = "ssr")] impl UpdateServerConfigRequest { /// Validate the update request. pub fn validate(&self) -> Result<(), AppError> { validation::validate_non_empty(&self.name, "Server name")?; validation::validate_range(self.max_users_per_channel, "Max users per channel", 1, 1000)?; validation::validate_range(self.message_rate_limit, "Message rate limit", 1, i32::MAX)?; validation::validate_range( self.message_rate_window_seconds, "Message rate window", 1, i32::MAX, )?; Ok(()) } } // ============================================================================= // Staff Models // ============================================================================= /// A server staff member (joined with user info). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct StaffMember { pub user_id: Uuid, pub username: String, pub display_name: String, pub email: Option, pub role: ServerRole, pub appointed_by: Option, pub appointed_at: DateTime, } // ============================================================================= // User Management Models // ============================================================================= /// User listing item (for tables). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct UserListItem { pub id: Uuid, pub username: String, pub display_name: String, pub email: Option, pub status: AccountStatus, pub reputation_tier: ReputationTier, pub staff_role: Option, pub created_at: DateTime, pub last_seen_at: Option>, } /// Full user detail (for user detail page). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct UserDetail { pub id: Uuid, pub username: String, pub email: Option, pub display_name: String, pub bio: Option, pub avatar_url: Option, pub reputation_tier: ReputationTier, pub status: AccountStatus, pub email_verified: bool, pub staff_role: Option, pub created_at: DateTime, pub updated_at: DateTime, pub last_seen_at: Option>, } /// User's realm membership (for user detail page). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct UserRealmMembership { pub realm_id: Uuid, pub realm_name: String, pub realm_slug: String, pub role: RealmRole, pub nickname: Option, pub joined_at: DateTime, pub last_visited_at: Option>, } // ============================================================================= // Staff Request Models // ============================================================================= /// Request to create a staff member. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateStaffRequest { /// Existing user ID to promote to staff. pub user_id: Option, /// Or create a new user. pub new_user: Option, /// Role to assign. pub role: ServerRole, } #[cfg(feature = "ssr")] impl CreateStaffRequest { /// Validate the create staff request. pub fn validate(&self) -> Result<(), AppError> { // Must have either user_id or new_user, not both match (&self.user_id, &self.new_user) { (None, None) => { return Err(AppError::Validation( "Must provide either user_id or new_user".to_string(), )); } (Some(_), Some(_)) => { return Err(AppError::Validation( "Cannot provide both user_id and new_user".to_string(), )); } _ => {} } // Validate new_user if provided if let Some(new_user) = &self.new_user { new_user.validate()?; } Ok(()) } } /// Data for creating a new user (password is auto-generated). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewUserData { pub username: String, pub email: String, pub display_name: String, } #[cfg(feature = "ssr")] impl NewUserData { /// Validate new user data. pub fn validate(&self) -> Result<(), AppError> { validation::validate_non_empty(&self.username, "Username")?; validation::validate_length(&self.username, "Username", 3, 32)?; validation::validate_email(&self.email)?; validation::validate_non_empty(&self.display_name, "Display name")?; Ok(()) } } /// Request to update a user's account status. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateUserStatusRequest { pub status: AccountStatus, } /// Request to add a user to a realm. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AddUserToRealmRequest { pub realm_id: Uuid, pub role: RealmRole, } /// Request to create a standalone user (from owner interface). /// Password is auto-generated as a random token. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateUserRequest { pub username: String, pub email: String, pub display_name: String, /// Optional: make this user a staff member. pub staff_role: Option, } #[cfg(feature = "ssr")] impl CreateUserRequest { /// Validate the create user request. pub fn validate(&self) -> Result<(), AppError> { validation::validate_non_empty(&self.username, "Username")?; validation::validate_length(&self.username, "Username", 3, 32)?; validation::validate_email(&self.email)?; validation::validate_non_empty(&self.display_name, "Display name")?; Ok(()) } } /// Request to create a realm from the owner interface. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OwnerCreateRealmRequest { /// Realm name. pub name: String, /// Realm slug (URL-friendly identifier). pub slug: String, /// Optional description. pub description: Option, /// Optional tagline. pub tagline: Option, /// Privacy setting. pub privacy: RealmPrivacy, /// Is this realm NSFW? pub is_nsfw: bool, /// Maximum concurrent users. pub max_users: i32, /// Allow guest access? pub allow_guest_access: bool, /// Optional theme color. pub theme_color: Option, /// Existing user ID to make owner. pub owner_id: Option, /// Or create a new user as owner. pub new_owner: Option, } #[cfg(feature = "ssr")] impl OwnerCreateRealmRequest { /// Validate the create realm request. pub fn validate(&self) -> Result<(), AppError> { validation::validate_non_empty(&self.name, "Realm name")?; validation::validate_slug(&self.slug)?; validation::validate_range(self.max_users, "Max users", 1, 10000)?; validation::validate_optional_hex_color(self.theme_color.as_deref())?; // Must have either owner_id or new_owner match (&self.owner_id, &self.new_owner) { (None, None) => { return Err(AppError::Validation( "Must provide either owner_id or new_owner".to_string(), )); } (Some(_), Some(_)) => { return Err(AppError::Validation( "Cannot provide both owner_id and new_owner".to_string(), )); } (None, Some(new_owner)) => { new_owner.validate()?; } _ => {} } Ok(()) } } /// Realm listing item for owner interface. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct RealmListItem { pub id: Uuid, pub name: String, pub slug: String, pub tagline: Option, pub privacy: RealmPrivacy, pub is_nsfw: bool, pub owner_id: Uuid, pub owner_username: String, pub member_count: i32, pub current_user_count: i32, pub created_at: DateTime, } /// Full realm detail for owner interface. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct RealmDetail { pub id: Uuid, pub name: String, pub slug: String, pub description: Option, pub tagline: Option, pub owner_id: Uuid, pub owner_username: String, pub owner_display_name: String, pub privacy: RealmPrivacy, pub is_nsfw: bool, pub min_reputation_tier: ReputationTier, pub theme_color: Option, pub banner_image_path: Option, pub thumbnail_path: Option, pub max_users: i32, pub allow_guest_access: bool, pub allow_user_teleport: bool, pub member_count: i32, pub current_user_count: i32, pub created_at: DateTime, pub updated_at: DateTime, } /// Request to update a realm. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateRealmRequest { pub name: String, pub description: Option, pub tagline: Option, pub privacy: RealmPrivacy, pub is_nsfw: bool, pub max_users: i32, pub allow_guest_access: bool, pub allow_user_teleport: bool, pub theme_color: Option, } #[cfg(feature = "ssr")] impl UpdateRealmRequest { /// Validate the update realm request. pub fn validate(&self) -> Result<(), AppError> { validation::validate_non_empty(&self.name, "Realm name")?; validation::validate_range(self.max_users, "Max users", 1, 10000)?; validation::validate_optional_hex_color(self.theme_color.as_deref())?; Ok(()) } } // ============================================================================= // Authentication Models // ============================================================================= /// Login type for authentication. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LoginType { Staff, Realm, } /// User with authentication fields for login verification. #[derive(Debug, Clone)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct UserWithAuth { pub id: Uuid, pub username: String, pub email: Option, pub display_name: String, pub avatar_url: Option, pub status: AccountStatus, pub force_pw_reset: bool, pub password_hash: Option, } /// Request to login. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoginRequest { pub username: String, pub password: String, pub login_type: LoginType, /// Required if login_type is Realm. pub realm_slug: Option, } #[cfg(feature = "ssr")] impl LoginRequest { /// Validate the login request. pub fn validate(&self) -> Result<(), AppError> { validation::validate_non_empty(&self.username, "Username")?; validation::validate_non_empty(&self.password, "Password")?; if self.login_type == LoginType::Realm && self.realm_slug.is_none() { return Err(AppError::Validation( "Realm slug is required for realm login".to_string(), )); } Ok(()) } } /// Response after successful login. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoginResponse { pub user: UserSummary, pub redirect_url: String, pub requires_pw_reset: bool, /// For realm login: whether user is already a member. pub is_member: Option, /// Original destination for redirect after password reset. pub original_destination: Option, /// Staff role if logging in as staff. pub staff_role: Option, /// Realm info if logging into a realm (for join confirmation). pub realm: Option, } /// Request to reset password. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PasswordResetRequest { pub new_password: String, pub confirm_password: String, } #[cfg(feature = "ssr")] impl PasswordResetRequest { /// Validate the password reset request. pub fn validate(&self) -> Result<(), AppError> { validation::validate_password(&self.new_password)?; validation::validate_passwords_match(&self.new_password, &self.confirm_password)?; Ok(()) } } /// Response after password reset. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PasswordResetResponse { pub success: bool, pub redirect_url: String, } /// Request to sign up (create a new account and join a realm). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignupRequest { pub username: String, pub email: Option, pub display_name: String, pub password: String, pub confirm_password: String, pub realm_slug: String, } #[cfg(feature = "ssr")] impl SignupRequest { /// Validate the signup request. pub fn validate(&self) -> Result<(), AppError> { validation::validate_username(&self.username)?; validation::validate_non_empty(&self.display_name, "Display name")?; validation::validate_length(self.display_name.trim(), "Display name", 1, 50)?; // Email: basic format if provided and non-empty if let Some(ref email) = self.email { let email_trimmed = email.trim(); if !email_trimmed.is_empty() && !validation::is_valid_email(email_trimmed) { return Err(AppError::Validation("Invalid email address".to_string())); } } validation::validate_password(&self.password)?; validation::validate_passwords_match(&self.password, &self.confirm_password)?; // Realm slug: required if self.realm_slug.trim().is_empty() { return Err(AppError::Validation( "Please select a realm to join".to_string(), )); } Ok(()) } } /// Response after successful signup. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignupResponse { pub user: UserSummary, pub redirect_url: String, pub membership_id: Uuid, } /// Request to login as a guest. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GuestLoginRequest { pub realm_slug: String, } #[cfg(feature = "ssr")] impl GuestLoginRequest { /// Validate the guest login request. pub fn validate(&self) -> Result<(), AppError> { validation::validate_non_empty(&self.realm_slug, "Realm")?; Ok(()) } } /// Response after successful guest login. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GuestLoginResponse { pub guest_name: String, pub user_id: Uuid, pub redirect_url: String, pub realm: RealmSummary, } /// Request to join a realm. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JoinRealmRequest { pub realm_id: Uuid, } /// Response after joining a realm. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JoinRealmResponse { pub success: bool, pub membership_id: Uuid, pub redirect_url: String, } /// Current user info (for /api/auth/me endpoint). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CurrentUserResponse { pub user: Option, } /// Authenticated user info. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthenticatedUser { pub id: Uuid, pub username: String, pub display_name: String, pub avatar_url: Option, pub staff_role: Option, } // ============================================================================= // Membership Models // ============================================================================= /// A realm membership record. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Membership { pub id: Uuid, pub realm_id: Uuid, pub user_id: Uuid, pub role: RealmRole, pub nickname: Option, pub joined_at: DateTime, pub last_visited_at: Option>, } /// Membership with realm info for user's realm list. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct MembershipWithRealm { pub membership_id: Uuid, pub realm_id: Uuid, pub realm_name: String, pub realm_slug: String, pub realm_privacy: RealmPrivacy, pub role: RealmRole, pub nickname: Option, pub last_visited_at: Option>, } // ============================================================================= // Scene Request/Response Models // ============================================================================= /// Request to create a new scene. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateSceneRequest { pub name: String, pub slug: String, pub description: Option, /// URL to download background image from. Server stores locally and sets background_image_path. pub background_image_url: Option, /// If true and background_image_url is provided, extract dimensions from the image. #[serde(default)] pub infer_dimensions_from_image: bool, /// Set directly only if not using background_image_url. pub background_image_path: Option, pub background_color: Option, /// Bounds as WKT string (e.g., "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))") pub bounds_wkt: Option, pub dimension_mode: Option, pub sort_order: Option, pub is_entry_point: Option, pub is_hidden: Option, } #[cfg(feature = "ssr")] impl CreateSceneRequest { /// Validate the create scene request. pub fn validate(&self) -> Result<(), AppError> { validation::validate_non_empty(&self.name, "Scene name")?; validation::validate_slug(&self.slug)?; validation::validate_optional_hex_color(self.background_color.as_deref())?; Ok(()) } } /// Request to update a scene. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateSceneRequest { pub name: Option, pub description: Option, /// URL to download background image from. Server stores locally and sets background_image_path. pub background_image_url: Option, /// If true and background_image_url is provided, extract dimensions from the image. #[serde(default)] pub infer_dimensions_from_image: bool, /// Set to true to clear the existing background image. #[serde(default)] pub clear_background_image: bool, /// Set directly only if not using background_image_url. pub background_image_path: Option, pub background_color: Option, pub bounds_wkt: Option, pub dimension_mode: Option, pub sort_order: Option, pub is_entry_point: Option, pub is_hidden: Option, } #[cfg(feature = "ssr")] impl UpdateSceneRequest { /// Validate the update scene request. pub fn validate(&self) -> Result<(), AppError> { // Validate name if provided if let Some(ref name) = self.name { validation::validate_non_empty(name, "Scene name")?; } validation::validate_optional_hex_color(self.background_color.as_deref())?; Ok(()) } } /// Response after creating a scene. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateSceneResponse { pub id: Uuid, pub slug: String, } /// Response for scene list. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SceneListResponse { pub scenes: Vec, } // ============================================================================= // Spot Request/Response Models // ============================================================================= /// Request to create a new spot. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateSpotRequest { pub name: Option, pub slug: Option, /// Region as WKT string (e.g., "POLYGON((100 100, 200 100, 200 200, 100 200, 100 100))") pub region_wkt: String, pub spot_type: Option, pub destination_scene_id: Option, /// Destination position as WKT string (e.g., "POINT(400 300)") pub destination_position_wkt: Option, pub sort_order: Option, pub is_visible: Option, pub is_active: Option, } #[cfg(feature = "ssr")] impl CreateSpotRequest { /// Validate the create spot request. pub fn validate(&self) -> Result<(), AppError> { // Validate slug if provided if let Some(ref slug) = self.slug { validation::validate_slug(slug)?; } validation::validate_non_empty(&self.region_wkt, "Region WKT")?; // Validate door type has destination if self.spot_type == Some(SpotType::Door) && self.destination_scene_id.is_none() { return Err(AppError::Validation( "Door spots must have a destination scene".to_string(), )); } Ok(()) } } /// Request to update a spot. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateSpotRequest { pub name: Option, pub slug: Option, pub region_wkt: Option, pub spot_type: Option, pub destination_scene_id: Option, pub destination_position_wkt: Option, pub current_state: Option, pub sort_order: Option, pub is_visible: Option, pub is_active: Option, } #[cfg(feature = "ssr")] impl UpdateSpotRequest { /// Validate the update spot request. pub fn validate(&self) -> Result<(), AppError> { // Validate slug if provided if let Some(ref slug) = self.slug { validation::validate_slug(slug)?; } // Validate region_wkt if provided if let Some(ref wkt) = self.region_wkt { validation::validate_non_empty(wkt, "Region WKT")?; } Ok(()) } } /// Response after creating a spot. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateSpotResponse { pub id: Uuid, } /// Response for spot list. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpotListResponse { pub spots: Vec, } // ============================================================================= // Channel Member Models // ============================================================================= /// A user's presence in a channel. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ChannelMember { pub id: Uuid, pub channel_id: Uuid, pub user_id: Option, pub guest_session_id: Option, /// X coordinate in scene space pub position_x: f64, /// Y coordinate in scene space pub position_y: f64, /// Facing direction in degrees (0-359) pub facing_direction: i16, pub is_moving: bool, pub is_afk: bool, pub joined_at: DateTime, pub last_moved_at: DateTime, } /// Channel member with user info for display. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct ChannelMemberInfo { pub id: Uuid, pub channel_id: Uuid, pub user_id: Option, pub guest_session_id: Option, /// Display name (user's display_name or guest's guest_name) pub display_name: String, /// X coordinate in scene space pub position_x: f64, /// Y coordinate in scene space pub position_y: f64, /// Facing direction in degrees (0-359) pub facing_direction: i16, pub is_moving: bool, pub is_afk: bool, /// Current emotion slot (0-9) pub current_emotion: i16, pub joined_at: DateTime, /// Whether this user is a guest (has the 'guest' tag) #[serde(default)] pub is_guest: bool, } /// Request to update position in a channel. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdatePositionRequest { pub x: f64, pub y: f64, } /// Request to switch emotion. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateEmotionRequest { /// Emotion slot 0-9 (e0-e9 hotkeys) pub emotion: u8, } // ============================================================================= // Avatar Render Data Models // ============================================================================= /// Data needed to render an avatar's current appearance. /// Contains the asset paths for all equipped props. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AvatarRenderData { pub avatar_id: Uuid, pub current_emotion: i16, /// Asset paths for skin layer positions 0-8 (None if slot empty) pub skin_layer: [Option; 9], /// Asset paths for clothes layer positions 0-8 pub clothes_layer: [Option; 9], /// Asset paths for accessories layer positions 0-8 pub accessories_layer: [Option; 9], /// Asset paths for current emotion overlay positions 0-8 pub emotion_layer: [Option; 9], } impl Default for AvatarRenderData { fn default() -> Self { Self { avatar_id: Uuid::nil(), current_emotion: 0, skin_layer: Default::default(), clothes_layer: Default::default(), accessories_layer: Default::default(), emotion_layer: Default::default(), } } } /// Channel member with full avatar render data. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ChannelMemberWithAvatar { pub member: ChannelMemberInfo, pub avatar: AvatarRenderData, } /// Response for channel members list. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChannelMembersResponse { pub members: Vec, } /// Response after joining a channel. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JoinChannelResponse { pub member: ChannelMemberInfo, pub members: Vec, } // ============================================================================= // Emotion Availability // ============================================================================= /// Emotion availability data for the emote command UI. /// /// Indicates which of the 12 emotions have assets configured for the user's avatar, /// and provides preview paths (position 4/center) for rendering in the emotion picker. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EmotionAvailability { /// Which emotions have at least one non-null asset slot (positions 0-8). /// Index corresponds to emotion: 0=neutral, 1=happy, 2=sad, etc. pub available: [bool; 12], /// Center position (4) asset path for each emotion, used for preview rendering. /// None if that emotion has no center asset. pub preview_paths: [Option; 12], } impl Default for EmotionAvailability { fn default() -> Self { Self { available: [false; 12], preview_paths: Default::default(), } } } // ============================================================================= // Full Avatar with Paths // ============================================================================= /// Full avatar data with all inventory UUIDs resolved to asset paths. /// /// This struct contains all 135 slots (27 content layer + 108 emotion layer) /// with paths pre-resolved, enabling client-side emotion availability computation /// and rendering without additional server queries. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AvatarWithPaths { pub avatar_id: Uuid, pub current_emotion: i16, /// Asset paths for skin layer positions 0-8 pub skin_layer: [Option; 9], /// Asset paths for clothes layer positions 0-8 pub clothes_layer: [Option; 9], /// Asset paths for accessories layer positions 0-8 pub accessories_layer: [Option; 9], /// Asset paths for all 12 emotions, each with 9 positions. /// Index: emotions[emotion_index][position] where emotion_index is 0-11 /// (neutral, happy, sad, angry, surprised, thinking, laughing, crying, love, confused, sleeping, wink) pub emotions: [[Option; 9]; 12], /// Whether each emotion has at least one slot populated (UUID exists, even if path lookup failed). /// This matches the old get_emotion_availability behavior. pub emotions_available: [bool; 12], } impl Default for AvatarWithPaths { fn default() -> Self { Self { avatar_id: Uuid::nil(), current_emotion: 0, skin_layer: Default::default(), clothes_layer: Default::default(), accessories_layer: Default::default(), emotions: Default::default(), emotions_available: [false; 12], } } } impl AvatarWithPaths { /// Compute emotion availability from the avatar data. /// Uses pre-computed emotions_available which checks if UUIDs exist (not just resolved paths). pub fn compute_emotion_availability(&self) -> EmotionAvailability { let mut preview_paths: [Option; 12] = Default::default(); for (i, emotion_layer) in self.emotions.iter().enumerate() { // Preview is position 4 (center) preview_paths[i] = emotion_layer[4].clone(); } EmotionAvailability { available: self.emotions_available, preview_paths, } } /// Get the 9-path emotion layer for a specific emotion index (0-11). pub fn get_emotion_layer(&self, emotion: usize) -> [Option; 9] { if emotion < 12 { self.emotions[emotion].clone() } else { Default::default() } } /// Convert to AvatarRenderData for the current emotion. pub fn to_render_data(&self) -> AvatarRenderData { AvatarRenderData { avatar_id: self.avatar_id, current_emotion: self.current_emotion, skin_layer: self.skin_layer.clone(), clothes_layer: self.clothes_layer.clone(), accessories_layer: self.accessories_layer.clone(), emotion_layer: self.get_emotion_layer(self.current_emotion as usize), } } } // ============================================================================= // Avatar Slot Update Models // ============================================================================= /// Request to assign an inventory item to an avatar slot. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AssignSlotRequest { /// Inventory item ID to assign to the slot. pub inventory_item_id: Uuid, /// Layer type: "skin", "clothes", "accessories", or an emotion name. pub layer: String, /// Grid position (0-8). pub position: u8, } #[cfg(feature = "ssr")] impl AssignSlotRequest { /// Validate the assign slot request. pub fn validate(&self) -> Result<(), AppError> { if self.position > 8 { return Err(AppError::Validation( "Position must be between 0 and 8".to_string(), )); } // Validate layer name let valid_layers = [ "skin", "clothes", "accessories", "neutral", "happy", "sad", "angry", "surprised", "thinking", "laughing", "crying", "love", "confused", "sleeping", "wink", ]; if !valid_layers.contains(&self.layer.to_lowercase().as_str()) { return Err(AppError::Validation(format!( "Invalid layer: {}", self.layer ))); } Ok(()) } /// Get the database column name for this slot. pub fn column_name(&self) -> String { let layer = self.layer.to_lowercase(); match layer.as_str() { "skin" => format!("l_skin_{}", self.position), "clothes" => format!("l_clothes_{}", self.position), "accessories" => format!("l_accessories_{}", self.position), _ => format!("e_{}_{}", layer, self.position), } } } /// Request to clear an avatar slot. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClearSlotRequest { /// Layer type: "skin", "clothes", "accessories", or an emotion name. pub layer: String, /// Grid position (0-8). pub position: u8, } #[cfg(feature = "ssr")] impl ClearSlotRequest { /// Validate the clear slot request. pub fn validate(&self) -> Result<(), AppError> { if self.position > 8 { return Err(AppError::Validation( "Position must be between 0 and 8".to_string(), )); } // Validate layer name let valid_layers = [ "skin", "clothes", "accessories", "neutral", "happy", "sad", "angry", "surprised", "thinking", "laughing", "crying", "love", "confused", "sleeping", "wink", ]; if !valid_layers.contains(&self.layer.to_lowercase().as_str()) { return Err(AppError::Validation(format!( "Invalid layer: {}", self.layer ))); } Ok(()) } /// Get the database column name for this slot. pub fn column_name(&self) -> String { let layer = self.layer.to_lowercase(); match layer.as_str() { "skin" => format!("l_skin_{}", self.position), "clothes" => format!("l_clothes_{}", self.position), "accessories" => format!("l_accessories_{}", self.position), _ => format!("e_{}_{}", layer, self.position), } } }