diff --git a/crates/chattyness-admin-ui/src/api.rs b/crates/chattyness-admin-ui/src/api.rs index b4f70d7..1c6c1db 100644 --- a/crates/chattyness-admin-ui/src/api.rs +++ b/crates/chattyness-admin-ui/src/api.rs @@ -9,8 +9,6 @@ pub mod config; #[cfg(feature = "ssr")] pub mod dashboard; #[cfg(feature = "ssr")] -pub mod loose_props; -#[cfg(feature = "ssr")] pub mod props; #[cfg(feature = "ssr")] pub mod realms; diff --git a/crates/chattyness-admin-ui/src/api/avatars.rs b/crates/chattyness-admin-ui/src/api/avatars.rs index 4542443..4eb8723 100644 --- a/crates/chattyness-admin-ui/src/api/avatars.rs +++ b/crates/chattyness-admin-ui/src/api/avatars.rs @@ -6,7 +6,7 @@ use chattyness_db::{ models::{CreateServerAvatarRequest, ServerAvatar, ServerAvatarSummary, UpdateServerAvatarRequest}, queries::server_avatars, }; -use chattyness_error::{AppError, OptionExt}; +use chattyness_error::AppError; use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; @@ -87,7 +87,7 @@ pub async fn get_avatar( ) -> Result, AppError> { let avatar = server_avatars::get_server_avatar_by_id(&pool, avatar_id) .await? - .or_not_found("Avatar")?; + .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; Ok(Json(avatar)) } @@ -106,7 +106,7 @@ pub async fn update_avatar( // Check avatar exists let existing = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id) .await? - .or_not_found("Avatar")?; + .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; // Update the avatar let avatar = server_avatars::update_server_avatar(&mut *guard, avatar_id, &req).await?; @@ -127,7 +127,7 @@ pub async fn delete_avatar( // Get the avatar first to log its name let avatar = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id) .await? - .or_not_found("Avatar")?; + .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; // Delete from database server_avatars::delete_server_avatar(&mut *guard, avatar_id).await?; diff --git a/crates/chattyness-admin-ui/src/api/loose_props.rs b/crates/chattyness-admin-ui/src/api/loose_props.rs deleted file mode 100644 index a1446bd..0000000 --- a/crates/chattyness-admin-ui/src/api/loose_props.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Loose props management API handlers for admin UI. - -use axum::Json; -use axum::extract::Path; -use chattyness_db::{models::LooseProp, queries::loose_props}; -use chattyness_error::{AppError, OptionExt}; -use serde::Deserialize; -use uuid::Uuid; - -use crate::auth::AdminConn; - -// ============================================================================= -// API Types -// ============================================================================= - -/// Request to update loose prop scale. -#[derive(Debug, Deserialize)] -pub struct UpdateLoosePropScaleRequest { - /// Scale factor (0.1 - 10.0). - pub scale: f32, -} - -// ============================================================================= -// API Handlers -// ============================================================================= - -/// Get a loose prop by ID. -pub async fn get_loose_prop( - admin_conn: AdminConn, - Path(loose_prop_id): Path, -) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - - let prop = loose_props::get_loose_prop_by_id(&mut *guard, loose_prop_id) - .await? - .or_not_found("Loose prop (may have expired)")?; - - Ok(Json(prop)) -} - -/// Update loose prop scale. -/// -/// Server admins can update any loose prop scale. -pub async fn update_loose_prop_scale( - admin_conn: AdminConn, - Path(loose_prop_id): Path, - Json(req): Json, -) -> Result, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - - let prop = loose_props::update_loose_prop_scale(&mut *guard, loose_prop_id, req.scale).await?; - - tracing::info!( - "Updated loose prop {} scale to {}", - loose_prop_id, - req.scale - ); - - Ok(Json(prop)) -} - -/// List loose props in a scene/channel. -pub async fn list_loose_props( - admin_conn: AdminConn, - Path(scene_id): Path, -) -> Result>, AppError> { - let conn = admin_conn.0; - let mut guard = conn.acquire().await; - - let props = loose_props::list_channel_loose_props(&mut *guard, scene_id).await?; - - Ok(Json(props)) -} diff --git a/crates/chattyness-admin-ui/src/api/props.rs b/crates/chattyness-admin-ui/src/api/props.rs index 549a08a..c7c7d7f 100644 --- a/crates/chattyness-admin-ui/src/api/props.rs +++ b/crates/chattyness-admin-ui/src/api/props.rs @@ -7,7 +7,7 @@ use chattyness_db::{ models::{CreateServerPropRequest, ServerProp, ServerPropSummary}, queries::props, }; -use chattyness_error::{AppError, OptionExt}; +use chattyness_error::AppError; use serde::Deserialize; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -218,7 +218,7 @@ pub async fn get_prop( ) -> Result, AppError> { let prop = props::get_server_prop_by_id(&pool, prop_id) .await? - .or_not_found("Prop")?; + .ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?; Ok(Json(prop)) } @@ -233,7 +233,7 @@ pub async fn delete_prop( // Get the prop first to get the asset path let prop = props::get_server_prop_by_id(&mut *guard, prop_id) .await? - .or_not_found("Prop")?; + .ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?; // Delete from database props::delete_server_prop(&mut *guard, prop_id).await?; diff --git a/crates/chattyness-admin-ui/src/api/realms.rs b/crates/chattyness-admin-ui/src/api/realms.rs index 92f1b9a..0cb1cbd 100644 --- a/crates/chattyness-admin-ui/src/api/realms.rs +++ b/crates/chattyness-admin-ui/src/api/realms.rs @@ -11,7 +11,7 @@ use chattyness_db::{ }, queries::{owner as queries, realm_avatars}, }; -use chattyness_error::{AppError, OptionExt}; +use chattyness_error::AppError; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; @@ -231,7 +231,7 @@ pub async fn get_realm_avatar( let avatar = realm_avatars::get_realm_avatar_by_id(&pool, avatar_id) .await? - .or_not_found("Avatar")?; + .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; Ok(Json(avatar)) } @@ -254,7 +254,7 @@ pub async fn update_realm_avatar( // Check avatar exists let existing = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id) .await? - .or_not_found("Avatar")?; + .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; // Update the avatar let avatar = realm_avatars::update_realm_avatar(&mut *guard, avatar_id, &req).await?; @@ -284,7 +284,7 @@ pub async fn delete_realm_avatar( // Get the avatar first to log its name let avatar = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id) .await? - .or_not_found("Avatar")?; + .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; // Delete from database realm_avatars::delete_realm_avatar(&mut *guard, avatar_id).await?; diff --git a/crates/chattyness-admin-ui/src/api/routes.rs b/crates/chattyness-admin-ui/src/api/routes.rs index a305074..3d85a34 100644 --- a/crates/chattyness-admin-ui/src/api/routes.rs +++ b/crates/chattyness-admin-ui/src/api/routes.rs @@ -5,7 +5,7 @@ use axum::{ routing::{delete, get, post, put}, }; -use super::{auth, avatars, config, dashboard, loose_props, props, realms, scenes, spots, staff, users}; +use super::{auth, avatars, config, dashboard, props, realms, scenes, spots, staff, users}; use crate::app::AdminAppState; /// Create the admin API router. @@ -85,19 +85,6 @@ pub fn admin_api_router() -> Router { "/props/{prop_id}", get(props::get_prop).delete(props::delete_prop), ) - // API - Loose Props (scene props) - .route( - "/scenes/{scene_id}/loose_props", - get(loose_props::list_loose_props), - ) - .route( - "/loose_props/{loose_prop_id}", - get(loose_props::get_loose_prop), - ) - .route( - "/loose_props/{loose_prop_id}/scale", - put(loose_props::update_loose_prop_scale), - ) // API - Server Avatars .route( "/avatars", diff --git a/crates/chattyness-admin-ui/src/api/scenes.rs b/crates/chattyness-admin-ui/src/api/scenes.rs index 5d7c649..c5d3d74 100644 --- a/crates/chattyness-admin-ui/src/api/scenes.rs +++ b/crates/chattyness-admin-ui/src/api/scenes.rs @@ -8,7 +8,7 @@ use chattyness_db::{ models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest}, queries::{realms, scenes}, }; -use chattyness_error::{AppError, OptionExt}; +use chattyness_error::AppError; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::path::PathBuf; @@ -199,7 +199,7 @@ pub async fn get_scene( ) -> Result, AppError> { let scene = scenes::get_scene_by_id(&pool, scene_id) .await? - .or_not_found("Scene")?; + .ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; Ok(Json(scene)) } @@ -273,7 +273,7 @@ pub async fn update_scene( // Get the existing scene to get realm_id let existing_scene = scenes::get_scene_by_id(&mut *guard, scene_id) .await? - .or_not_found("Scene")?; + .ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; // Handle clear background image if req.clear_background_image { diff --git a/crates/chattyness-admin-ui/src/api/spots.rs b/crates/chattyness-admin-ui/src/api/spots.rs index 8351ab7..7500f7b 100644 --- a/crates/chattyness-admin-ui/src/api/spots.rs +++ b/crates/chattyness-admin-ui/src/api/spots.rs @@ -8,7 +8,7 @@ use chattyness_db::{ models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest}, queries::spots, }; -use chattyness_error::{AppError, OptionExt}; +use chattyness_error::AppError; use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; @@ -31,7 +31,7 @@ pub async fn get_spot( ) -> Result, AppError> { let spot = spots::get_spot_by_id(&pool, spot_id) .await? - .or_not_found("Spot")?; + .ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; Ok(Json(spot)) } @@ -78,7 +78,7 @@ pub async fn update_spot( if let Some(ref new_slug) = req.slug { let existing = spots::get_spot_by_id(&mut *guard, spot_id) .await? - .or_not_found("Spot")?; + .ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; if Some(new_slug.clone()) != existing.slug { let available = diff --git a/crates/chattyness-admin-ui/src/models.rs b/crates/chattyness-admin-ui/src/models.rs index c16ed05..2e9dc7b 100644 --- a/crates/chattyness-admin-ui/src/models.rs +++ b/crates/chattyness-admin-ui/src/models.rs @@ -239,8 +239,6 @@ pub struct PropDetail { pub default_layer: Option, /// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8 pub default_position: Option, - /// Default scale factor (0.1 - 10.0) applied when prop is dropped to canvas. - pub default_scale: f32, pub is_unique: bool, pub is_transferable: bool, pub is_portable: bool, diff --git a/crates/chattyness-admin-ui/src/pages/avatars_new.rs b/crates/chattyness-admin-ui/src/pages/avatars_new.rs index 9ba1da2..a01b2fd 100644 --- a/crates/chattyness-admin-ui/src/pages/avatars_new.rs +++ b/crates/chattyness-admin-ui/src/pages/avatars_new.rs @@ -5,7 +5,6 @@ use leptos::prelude::*; use leptos::task::spawn_local; use crate::components::{Card, PageHeader}; -use crate::utils::name_to_slug; /// Server avatar new page component. #[component] @@ -29,7 +28,14 @@ pub fn AvatarsNewPage() -> impl IntoView { let new_name = event_target_value(&ev); set_name.set(new_name.clone()); if slug_auto.get() { - set_slug.set(name_to_slug(&new_name)); + let new_slug = new_name + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_string(); + set_slug.set(new_slug); } }; diff --git a/crates/chattyness-admin-ui/src/pages/props_detail.rs b/crates/chattyness-admin-ui/src/pages/props_detail.rs index f21e878..2ae1874 100644 --- a/crates/chattyness-admin-ui/src/pages/props_detail.rs +++ b/crates/chattyness-admin-ui/src/pages/props_detail.rs @@ -95,9 +95,6 @@ fn PropDetailView(prop: PropDetail) -> impl IntoView { None => "Not set".to_string(), }} - - {format!("{}%", (prop.default_scale * 100.0) as i32)} - {if prop.is_active { view! { "Active" }.into_any() diff --git a/crates/chattyness-admin-ui/src/pages/props_new.rs b/crates/chattyness-admin-ui/src/pages/props_new.rs index 1b97e8c..da961fe 100644 --- a/crates/chattyness-admin-ui/src/pages/props_new.rs +++ b/crates/chattyness-admin-ui/src/pages/props_new.rs @@ -5,7 +5,6 @@ use leptos::prelude::*; use leptos::task::spawn_local; use crate::components::{Card, PageHeader}; -use crate::utils::name_to_slug; /// Prop new page component with file upload. #[component] @@ -33,7 +32,14 @@ pub fn PropsNewPage() -> impl IntoView { let new_name = event_target_value(&ev); set_name.set(new_name.clone()); if slug_auto.get() { - set_slug.set(name_to_slug(&new_name)); + let new_slug = new_name + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_string(); + set_slug.set(new_slug); } }; diff --git a/crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs b/crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs index b41cb74..e8e8118 100644 --- a/crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs +++ b/crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs @@ -6,7 +6,6 @@ use leptos::task::spawn_local; use leptos_router::hooks::use_params_map; use crate::components::{Card, PageHeader}; -use crate::utils::name_to_slug; #[cfg(feature = "hydrate")] use crate::utils::get_api_base; @@ -37,7 +36,14 @@ pub fn RealmAvatarsNewPage() -> impl IntoView { let new_name = event_target_value(&ev); set_name.set(new_name.clone()); if slug_auto.get() { - set_avatar_slug.set(name_to_slug(&new_name)); + let new_slug = new_name + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_string(); + set_avatar_slug.set(new_slug); } }; diff --git a/crates/chattyness-admin-ui/src/pages/realm_new.rs b/crates/chattyness-admin-ui/src/pages/realm_new.rs index 0a7085d..f520428 100644 --- a/crates/chattyness-admin-ui/src/pages/realm_new.rs +++ b/crates/chattyness-admin-ui/src/pages/realm_new.rs @@ -5,7 +5,6 @@ use leptos::prelude::*; use leptos::task::spawn_local; use crate::components::{Card, PageHeader}; -use crate::utils::name_to_slug; /// Realm new page component. #[component] @@ -41,7 +40,14 @@ pub fn RealmNewPage() -> impl IntoView { let new_name = event_target_value(&ev); set_name.set(new_name.clone()); if slug_auto.get() { - set_slug.set(name_to_slug(&new_name)); + let new_slug = new_name + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_string(); + set_slug.set(new_slug); } }; diff --git a/crates/chattyness-admin-ui/src/pages/scene_new.rs b/crates/chattyness-admin-ui/src/pages/scene_new.rs index a4b9e8e..32dee1a 100644 --- a/crates/chattyness-admin-ui/src/pages/scene_new.rs +++ b/crates/chattyness-admin-ui/src/pages/scene_new.rs @@ -6,7 +6,6 @@ use leptos::task::spawn_local; use leptos_router::hooks::use_params_map; use crate::components::{Card, PageHeader}; -use crate::utils::name_to_slug; #[cfg(feature = "hydrate")] use crate::utils::fetch_image_dimensions_client; @@ -41,7 +40,14 @@ pub fn SceneNewPage() -> impl IntoView { let new_name = event_target_value(&ev); set_name.set(new_name.clone()); if slug_auto.get() { - set_slug.set(name_to_slug(&new_name)); + let new_slug = new_name + .to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_string(); + set_slug.set(new_slug); } }; diff --git a/crates/chattyness-admin-ui/src/utils.rs b/crates/chattyness-admin-ui/src/utils.rs index 4c5341f..77c37a2 100644 --- a/crates/chattyness-admin-ui/src/utils.rs +++ b/crates/chattyness-admin-ui/src/utils.rs @@ -1,24 +1,5 @@ //! Utility functions for the admin UI. -/// Generate a URL-friendly slug from a name. -/// -/// Converts to lowercase, replaces non-alphanumeric chars with hyphens, -/// and trims leading/trailing hyphens. -/// -/// # Example -/// ```rust -/// assert_eq!(name_to_slug("My Cool Realm!"), "my-cool-realm-"); -/// assert_eq!(name_to_slug("Test 123"), "test-123"); -/// ``` -pub fn name_to_slug(name: &str) -> String { - name.to_lowercase() - .chars() - .map(|c| if c.is_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - .to_string() -} - /// Gets the API base path based on the current URL. /// /// Returns `/api/admin` if the current path starts with `/admin`, diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index 3c0f37c..a1d11c5 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -889,8 +889,6 @@ pub struct LooseProp { pub realm_prop_id: Option, pub position_x: f64, pub position_y: f64, - /// Scale factor (0.1 - 10.0) inherited from prop definition at drop time. - pub scale: f32, pub dropped_by: Option, pub expires_at: Option>, pub created_at: DateTime, @@ -898,12 +896,6 @@ pub struct LooseProp { pub prop_name: String, /// Asset path for rendering (JOINed from source prop). pub prop_asset_path: String, - /// If true, only moderators can move/scale/pickup this prop. - #[serde(default)] - pub is_locked: bool, - /// User ID of the moderator who locked this prop. - #[serde(default)] - pub locked_by: Option, } /// A server-wide prop (global library). @@ -923,8 +915,6 @@ pub struct ServerProp { 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, - /// Default scale factor (0.1 - 10.0) applied when prop is dropped to canvas. - pub default_scale: f32, pub is_unique: bool, pub is_transferable: bool, pub is_portable: bool, @@ -976,9 +966,6 @@ pub struct CreateServerPropRequest { /// Whether prop appears in the public Server inventory tab. #[serde(default)] pub public: Option, - /// Default scale factor (0.1 - 10.0) applied when prop is dropped to canvas. - #[serde(default)] - pub default_scale: Option, } #[cfg(feature = "ssr")] @@ -1012,14 +999,6 @@ impl CreateServerPropRequest { .to_string(), )); } - // Validate scale range (0.1 - 10.0) - if let Some(scale) = self.default_scale { - if !(0.1..=10.0).contains(&scale) { - return Err(AppError::Validation( - "default_scale must be between 0.1 and 10.0".to_string(), - )); - } - } Ok(()) } diff --git a/crates/chattyness-db/src/queries.rs b/crates/chattyness-db/src/queries.rs index 47b6d7a..1f7eeb6 100644 --- a/crates/chattyness-db/src/queries.rs +++ b/crates/chattyness-db/src/queries.rs @@ -1,6 +1,5 @@ //! Database query modules. -pub mod avatar_common; pub mod avatars; pub mod channel_members; pub mod channels; diff --git a/crates/chattyness-db/src/queries/avatar_common.rs b/crates/chattyness-db/src/queries/avatar_common.rs deleted file mode 100644 index 97fa461..0000000 --- a/crates/chattyness-db/src/queries/avatar_common.rs +++ /dev/null @@ -1,657 +0,0 @@ -//! Common avatar query infrastructure. -//! -//! This module provides shared types and traits for server and realm avatars, -//! eliminating duplication between `server_avatars.rs` and `realm_avatars.rs`. - -use std::collections::HashMap; - -use sqlx::PgExecutor; -use uuid::Uuid; - -use crate::models::{AvatarRenderData, EmotionState}; -use chattyness_error::AppError; - -// ============================================================================= -// Avatar Slots - Array-based representation of avatar prop references -// ============================================================================= - -/// Number of positions per layer (0-8). -pub const LAYER_SIZE: usize = 9; - -/// Number of emotion types. -pub const EMOTION_COUNT: usize = 12; - -/// Array-based representation of avatar prop slots. -/// -/// This struct consolidates the 135 individual UUID fields into arrays, -/// making the code more maintainable and enabling iteration. -#[derive(Debug, Clone, Default)] -pub struct AvatarSlots { - /// Skin layer positions 0-8 (behind user, body/face). - pub skin: [Option; LAYER_SIZE], - /// Clothes layer positions 0-8 (with user, worn items). - pub clothes: [Option; LAYER_SIZE], - /// Accessories layer positions 0-8 (in front of user, held/attached items). - pub accessories: [Option; LAYER_SIZE], - /// Emotion layers: 12 emotions × 9 positions each. - /// Index by EmotionState ordinal: neutral=0, happy=1, sad=2, etc. - pub emotions: [[Option; LAYER_SIZE]; EMOTION_COUNT], -} - -impl AvatarSlots { - /// Get the emotion layer for a specific emotion state. - pub fn emotion_layer(&self, emotion: EmotionState) -> &[Option; LAYER_SIZE] { - &self.emotions[emotion.as_index()] - } - - /// Collect all non-null prop UUIDs from all layers. - pub fn collect_all_prop_ids(&self) -> Vec { - let mut ids = Vec::new(); - - // Content layers - for id in self.skin.iter().flatten() { - ids.push(*id); - } - for id in self.clothes.iter().flatten() { - ids.push(*id); - } - for id in self.accessories.iter().flatten() { - ids.push(*id); - } - - // All emotion layers - for emotion_layer in &self.emotions { - for id in emotion_layer.iter().flatten() { - ids.push(*id); - } - } - - ids - } - - /// Collect prop UUIDs for content layers + specific emotion. - pub fn collect_render_prop_ids(&self, emotion: EmotionState) -> Vec { - let mut ids = Vec::new(); - - // Content layers - for id in self.skin.iter().flatten() { - ids.push(*id); - } - for id in self.clothes.iter().flatten() { - ids.push(*id); - } - for id in self.accessories.iter().flatten() { - ids.push(*id); - } - - // Specific emotion layer - for id in self.emotion_layer(emotion).iter().flatten() { - ids.push(*id); - } - - ids - } -} - -/// Extension trait for EmotionState to get array indices. -pub trait EmotionIndex { - fn as_index(&self) -> usize; - fn from_index(index: usize) -> Option - where - Self: Sized; -} - -impl EmotionIndex for EmotionState { - fn as_index(&self) -> usize { - match self { - EmotionState::Neutral => 0, - EmotionState::Happy => 1, - EmotionState::Sad => 2, - EmotionState::Angry => 3, - EmotionState::Surprised => 4, - EmotionState::Thinking => 5, - EmotionState::Laughing => 6, - EmotionState::Crying => 7, - EmotionState::Love => 8, - EmotionState::Confused => 9, - EmotionState::Sleeping => 10, - EmotionState::Wink => 11, - } - } - - fn from_index(index: usize) -> Option { - match index { - 0 => Some(EmotionState::Neutral), - 1 => Some(EmotionState::Happy), - 2 => Some(EmotionState::Sad), - 3 => Some(EmotionState::Angry), - 4 => Some(EmotionState::Surprised), - 5 => Some(EmotionState::Thinking), - 6 => Some(EmotionState::Laughing), - 7 => Some(EmotionState::Crying), - 8 => Some(EmotionState::Love), - 9 => Some(EmotionState::Confused), - 10 => Some(EmotionState::Sleeping), - 11 => Some(EmotionState::Wink), - _ => None, - } - } -} - -// ============================================================================= -// Shared Row Types for Database Queries -// ============================================================================= - -/// Row type for avatar with resolved asset paths. -/// Used by both server and realm avatar queries. -#[derive(Debug, sqlx::FromRow)] -pub struct AvatarWithPathsRow { - pub id: Uuid, - pub slug: Option, // Server avatars have slug, realm might not in some queries - pub name: String, - pub description: Option, - // Skin layer paths - pub skin_0: Option, - pub skin_1: Option, - pub skin_2: Option, - pub skin_3: Option, - pub skin_4: Option, - pub skin_5: Option, - pub skin_6: Option, - pub skin_7: Option, - pub skin_8: Option, - // Clothes layer paths - pub clothes_0: Option, - pub clothes_1: Option, - pub clothes_2: Option, - pub clothes_3: Option, - pub clothes_4: Option, - pub clothes_5: Option, - pub clothes_6: Option, - pub clothes_7: Option, - pub clothes_8: Option, - // Accessories layer paths - pub accessories_0: Option, - pub accessories_1: Option, - pub accessories_2: Option, - pub accessories_3: Option, - pub accessories_4: Option, - pub accessories_5: Option, - pub accessories_6: Option, - pub accessories_7: Option, - pub accessories_8: Option, - // Happy emotion layer paths (e1 - for store display) - pub emotion_0: Option, - pub emotion_1: Option, - pub emotion_2: Option, - pub emotion_3: Option, - pub emotion_4: Option, - pub emotion_5: Option, - pub emotion_6: Option, - pub emotion_7: Option, - pub emotion_8: Option, -} - -impl AvatarWithPathsRow { - /// Extract skin layer as array. - pub fn skin_layer(&self) -> [Option; LAYER_SIZE] { - [ - self.skin_0.clone(), - self.skin_1.clone(), - self.skin_2.clone(), - self.skin_3.clone(), - self.skin_4.clone(), - self.skin_5.clone(), - self.skin_6.clone(), - self.skin_7.clone(), - self.skin_8.clone(), - ] - } - - /// Extract clothes layer as array. - pub fn clothes_layer(&self) -> [Option; LAYER_SIZE] { - [ - self.clothes_0.clone(), - self.clothes_1.clone(), - self.clothes_2.clone(), - self.clothes_3.clone(), - self.clothes_4.clone(), - self.clothes_5.clone(), - self.clothes_6.clone(), - self.clothes_7.clone(), - self.clothes_8.clone(), - ] - } - - /// Extract accessories layer as array. - pub fn accessories_layer(&self) -> [Option; LAYER_SIZE] { - [ - self.accessories_0.clone(), - self.accessories_1.clone(), - self.accessories_2.clone(), - self.accessories_3.clone(), - self.accessories_4.clone(), - self.accessories_5.clone(), - self.accessories_6.clone(), - self.accessories_7.clone(), - self.accessories_8.clone(), - ] - } - - /// Extract emotion layer as array. - pub fn emotion_layer(&self) -> [Option; LAYER_SIZE] { - [ - self.emotion_0.clone(), - self.emotion_1.clone(), - self.emotion_2.clone(), - self.emotion_3.clone(), - self.emotion_4.clone(), - self.emotion_5.clone(), - self.emotion_6.clone(), - self.emotion_7.clone(), - self.emotion_8.clone(), - ] - } -} - -/// Row type for prop asset lookup. -#[derive(Debug, sqlx::FromRow)] -pub struct PropAssetRow { - pub id: Uuid, - pub asset_path: String, -} - -// ============================================================================= -// Generic Query Helpers -// ============================================================================= - -/// Build a prop UUID to asset path lookup map. -pub async fn build_prop_map<'e>( - executor: impl PgExecutor<'e>, - prop_ids: &[Uuid], - props_table: &str, -) -> Result, AppError> { - if prop_ids.is_empty() { - return Ok(HashMap::new()); - } - - let query = format!( - r#" - SELECT id, asset_path - FROM {} - WHERE id = ANY($1) - "#, - props_table - ); - - let rows = sqlx::query_as::<_, PropAssetRow>(&query) - .bind(prop_ids) - .fetch_all(executor) - .await?; - - Ok(rows.into_iter().map(|r| (r.id, r.asset_path)).collect()) -} - -/// Resolve avatar slots to render data using a prop map. -pub fn resolve_slots_to_render_data( - avatar_id: Uuid, - slots: &AvatarSlots, - current_emotion: EmotionState, - prop_map: &HashMap, -) -> AvatarRenderData { - let get_path = |id: Option| -> Option { - id.and_then(|id| prop_map.get(&id).cloned()) - }; - - let emotion_layer = slots.emotion_layer(current_emotion); - - AvatarRenderData { - avatar_id, - current_emotion, - skin_layer: [ - get_path(slots.skin[0]), - get_path(slots.skin[1]), - get_path(slots.skin[2]), - get_path(slots.skin[3]), - get_path(slots.skin[4]), - get_path(slots.skin[5]), - get_path(slots.skin[6]), - get_path(slots.skin[7]), - get_path(slots.skin[8]), - ], - clothes_layer: [ - get_path(slots.clothes[0]), - get_path(slots.clothes[1]), - get_path(slots.clothes[2]), - get_path(slots.clothes[3]), - get_path(slots.clothes[4]), - get_path(slots.clothes[5]), - get_path(slots.clothes[6]), - get_path(slots.clothes[7]), - get_path(slots.clothes[8]), - ], - accessories_layer: [ - get_path(slots.accessories[0]), - get_path(slots.accessories[1]), - get_path(slots.accessories[2]), - get_path(slots.accessories[3]), - get_path(slots.accessories[4]), - get_path(slots.accessories[5]), - get_path(slots.accessories[6]), - get_path(slots.accessories[7]), - get_path(slots.accessories[8]), - ], - emotion_layer: [ - get_path(emotion_layer[0]), - get_path(emotion_layer[1]), - get_path(emotion_layer[2]), - get_path(emotion_layer[3]), - get_path(emotion_layer[4]), - get_path(emotion_layer[5]), - get_path(emotion_layer[6]), - get_path(emotion_layer[7]), - get_path(emotion_layer[8]), - ], - } -} - -// ============================================================================= -// SQL Generation Helpers -// ============================================================================= - -/// Generate the SELECT clause for avatar paths query. -/// Returns the column selections for joining props to get asset paths. -pub fn avatar_paths_select_clause() -> &'static str { - r#" - a.id, - a.name, - a.description, - -- Skin layer - p_skin_0.asset_path AS skin_0, - p_skin_1.asset_path AS skin_1, - p_skin_2.asset_path AS skin_2, - p_skin_3.asset_path AS skin_3, - p_skin_4.asset_path AS skin_4, - p_skin_5.asset_path AS skin_5, - p_skin_6.asset_path AS skin_6, - p_skin_7.asset_path AS skin_7, - p_skin_8.asset_path AS skin_8, - -- Clothes layer - p_clothes_0.asset_path AS clothes_0, - p_clothes_1.asset_path AS clothes_1, - p_clothes_2.asset_path AS clothes_2, - p_clothes_3.asset_path AS clothes_3, - p_clothes_4.asset_path AS clothes_4, - p_clothes_5.asset_path AS clothes_5, - p_clothes_6.asset_path AS clothes_6, - p_clothes_7.asset_path AS clothes_7, - p_clothes_8.asset_path AS clothes_8, - -- Accessories layer - p_acc_0.asset_path AS accessories_0, - p_acc_1.asset_path AS accessories_1, - p_acc_2.asset_path AS accessories_2, - p_acc_3.asset_path AS accessories_3, - p_acc_4.asset_path AS accessories_4, - p_acc_5.asset_path AS accessories_5, - p_acc_6.asset_path AS accessories_6, - p_acc_7.asset_path AS accessories_7, - p_acc_8.asset_path AS accessories_8, - -- Happy emotion layer (e1 - more inviting for store display) - p_emo_0.asset_path AS emotion_0, - p_emo_1.asset_path AS emotion_1, - p_emo_2.asset_path AS emotion_2, - p_emo_3.asset_path AS emotion_3, - p_emo_4.asset_path AS emotion_4, - p_emo_5.asset_path AS emotion_5, - p_emo_6.asset_path AS emotion_6, - p_emo_7.asset_path AS emotion_7, - p_emo_8.asset_path AS emotion_8 - "# -} - -/// Generate the JOIN clause for avatar paths query. -/// `props_table` should be "server.props" or "realm.props". -pub fn avatar_paths_join_clause(props_table: &str) -> String { - format!( - r#" - -- Skin layer joins - LEFT JOIN {props} p_skin_0 ON a.l_skin_0 = p_skin_0.id - LEFT JOIN {props} p_skin_1 ON a.l_skin_1 = p_skin_1.id - LEFT JOIN {props} p_skin_2 ON a.l_skin_2 = p_skin_2.id - LEFT JOIN {props} p_skin_3 ON a.l_skin_3 = p_skin_3.id - LEFT JOIN {props} p_skin_4 ON a.l_skin_4 = p_skin_4.id - LEFT JOIN {props} p_skin_5 ON a.l_skin_5 = p_skin_5.id - LEFT JOIN {props} p_skin_6 ON a.l_skin_6 = p_skin_6.id - LEFT JOIN {props} p_skin_7 ON a.l_skin_7 = p_skin_7.id - LEFT JOIN {props} p_skin_8 ON a.l_skin_8 = p_skin_8.id - -- Clothes layer joins - LEFT JOIN {props} p_clothes_0 ON a.l_clothes_0 = p_clothes_0.id - LEFT JOIN {props} p_clothes_1 ON a.l_clothes_1 = p_clothes_1.id - LEFT JOIN {props} p_clothes_2 ON a.l_clothes_2 = p_clothes_2.id - LEFT JOIN {props} p_clothes_3 ON a.l_clothes_3 = p_clothes_3.id - LEFT JOIN {props} p_clothes_4 ON a.l_clothes_4 = p_clothes_4.id - LEFT JOIN {props} p_clothes_5 ON a.l_clothes_5 = p_clothes_5.id - LEFT JOIN {props} p_clothes_6 ON a.l_clothes_6 = p_clothes_6.id - LEFT JOIN {props} p_clothes_7 ON a.l_clothes_7 = p_clothes_7.id - LEFT JOIN {props} p_clothes_8 ON a.l_clothes_8 = p_clothes_8.id - -- Accessories layer joins - LEFT JOIN {props} p_acc_0 ON a.l_accessories_0 = p_acc_0.id - LEFT JOIN {props} p_acc_1 ON a.l_accessories_1 = p_acc_1.id - LEFT JOIN {props} p_acc_2 ON a.l_accessories_2 = p_acc_2.id - LEFT JOIN {props} p_acc_3 ON a.l_accessories_3 = p_acc_3.id - LEFT JOIN {props} p_acc_4 ON a.l_accessories_4 = p_acc_4.id - LEFT JOIN {props} p_acc_5 ON a.l_accessories_5 = p_acc_5.id - LEFT JOIN {props} p_acc_6 ON a.l_accessories_6 = p_acc_6.id - LEFT JOIN {props} p_acc_7 ON a.l_accessories_7 = p_acc_7.id - LEFT JOIN {props} p_acc_8 ON a.l_accessories_8 = p_acc_8.id - -- Happy emotion layer joins (e1 - more inviting for store display) - LEFT JOIN {props} p_emo_0 ON a.e_happy_0 = p_emo_0.id - LEFT JOIN {props} p_emo_1 ON a.e_happy_1 = p_emo_1.id - LEFT JOIN {props} p_emo_2 ON a.e_happy_2 = p_emo_2.id - LEFT JOIN {props} p_emo_3 ON a.e_happy_3 = p_emo_3.id - LEFT JOIN {props} p_emo_4 ON a.e_happy_4 = p_emo_4.id - LEFT JOIN {props} p_emo_5 ON a.e_happy_5 = p_emo_5.id - LEFT JOIN {props} p_emo_6 ON a.e_happy_6 = p_emo_6.id - LEFT JOIN {props} p_emo_7 ON a.e_happy_7 = p_emo_7.id - LEFT JOIN {props} p_emo_8 ON a.e_happy_8 = p_emo_8.id - "#, - props = props_table - ) -} - -// ============================================================================= -// Macros for Avatar Slot Extraction -// ============================================================================= - -/// Extract AvatarSlots from an avatar struct with the standard field naming. -/// Works with both ServerAvatar and RealmAvatar. -#[macro_export] -macro_rules! extract_avatar_slots { - ($avatar:expr) => {{ - use $crate::queries::avatar_common::AvatarSlots; - - AvatarSlots { - skin: [ - $avatar.l_skin_0, - $avatar.l_skin_1, - $avatar.l_skin_2, - $avatar.l_skin_3, - $avatar.l_skin_4, - $avatar.l_skin_5, - $avatar.l_skin_6, - $avatar.l_skin_7, - $avatar.l_skin_8, - ], - clothes: [ - $avatar.l_clothes_0, - $avatar.l_clothes_1, - $avatar.l_clothes_2, - $avatar.l_clothes_3, - $avatar.l_clothes_4, - $avatar.l_clothes_5, - $avatar.l_clothes_6, - $avatar.l_clothes_7, - $avatar.l_clothes_8, - ], - accessories: [ - $avatar.l_accessories_0, - $avatar.l_accessories_1, - $avatar.l_accessories_2, - $avatar.l_accessories_3, - $avatar.l_accessories_4, - $avatar.l_accessories_5, - $avatar.l_accessories_6, - $avatar.l_accessories_7, - $avatar.l_accessories_8, - ], - emotions: [ - // Neutral (e0) - [ - $avatar.e_neutral_0, - $avatar.e_neutral_1, - $avatar.e_neutral_2, - $avatar.e_neutral_3, - $avatar.e_neutral_4, - $avatar.e_neutral_5, - $avatar.e_neutral_6, - $avatar.e_neutral_7, - $avatar.e_neutral_8, - ], - // Happy (e1) - [ - $avatar.e_happy_0, - $avatar.e_happy_1, - $avatar.e_happy_2, - $avatar.e_happy_3, - $avatar.e_happy_4, - $avatar.e_happy_5, - $avatar.e_happy_6, - $avatar.e_happy_7, - $avatar.e_happy_8, - ], - // Sad (e2) - [ - $avatar.e_sad_0, - $avatar.e_sad_1, - $avatar.e_sad_2, - $avatar.e_sad_3, - $avatar.e_sad_4, - $avatar.e_sad_5, - $avatar.e_sad_6, - $avatar.e_sad_7, - $avatar.e_sad_8, - ], - // Angry (e3) - [ - $avatar.e_angry_0, - $avatar.e_angry_1, - $avatar.e_angry_2, - $avatar.e_angry_3, - $avatar.e_angry_4, - $avatar.e_angry_5, - $avatar.e_angry_6, - $avatar.e_angry_7, - $avatar.e_angry_8, - ], - // Surprised (e4) - [ - $avatar.e_surprised_0, - $avatar.e_surprised_1, - $avatar.e_surprised_2, - $avatar.e_surprised_3, - $avatar.e_surprised_4, - $avatar.e_surprised_5, - $avatar.e_surprised_6, - $avatar.e_surprised_7, - $avatar.e_surprised_8, - ], - // Thinking (e5) - [ - $avatar.e_thinking_0, - $avatar.e_thinking_1, - $avatar.e_thinking_2, - $avatar.e_thinking_3, - $avatar.e_thinking_4, - $avatar.e_thinking_5, - $avatar.e_thinking_6, - $avatar.e_thinking_7, - $avatar.e_thinking_8, - ], - // Laughing (e6) - [ - $avatar.e_laughing_0, - $avatar.e_laughing_1, - $avatar.e_laughing_2, - $avatar.e_laughing_3, - $avatar.e_laughing_4, - $avatar.e_laughing_5, - $avatar.e_laughing_6, - $avatar.e_laughing_7, - $avatar.e_laughing_8, - ], - // Crying (e7) - [ - $avatar.e_crying_0, - $avatar.e_crying_1, - $avatar.e_crying_2, - $avatar.e_crying_3, - $avatar.e_crying_4, - $avatar.e_crying_5, - $avatar.e_crying_6, - $avatar.e_crying_7, - $avatar.e_crying_8, - ], - // Love (e8) - [ - $avatar.e_love_0, - $avatar.e_love_1, - $avatar.e_love_2, - $avatar.e_love_3, - $avatar.e_love_4, - $avatar.e_love_5, - $avatar.e_love_6, - $avatar.e_love_7, - $avatar.e_love_8, - ], - // Confused (e9) - [ - $avatar.e_confused_0, - $avatar.e_confused_1, - $avatar.e_confused_2, - $avatar.e_confused_3, - $avatar.e_confused_4, - $avatar.e_confused_5, - $avatar.e_confused_6, - $avatar.e_confused_7, - $avatar.e_confused_8, - ], - // Sleeping (e10) - [ - $avatar.e_sleeping_0, - $avatar.e_sleeping_1, - $avatar.e_sleeping_2, - $avatar.e_sleeping_3, - $avatar.e_sleeping_4, - $avatar.e_sleeping_5, - $avatar.e_sleeping_6, - $avatar.e_sleeping_7, - $avatar.e_sleeping_8, - ], - // Wink (e11) - [ - $avatar.e_wink_0, - $avatar.e_wink_1, - $avatar.e_wink_2, - $avatar.e_wink_3, - $avatar.e_wink_4, - $avatar.e_wink_5, - $avatar.e_wink_6, - $avatar.e_wink_7, - $avatar.e_wink_8, - ], - ], - } - }}; -} - -pub use extract_avatar_slots; diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index 70eb3cb..415d907 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -6,7 +6,7 @@ use sqlx::PgExecutor; use uuid::Uuid; use crate::models::{InventoryItem, LooseProp}; -use chattyness_error::{AppError, OptionExt}; +use chattyness_error::AppError; /// Ensure an instance exists for a scene. /// @@ -46,14 +46,11 @@ pub async fn list_channel_loose_props<'e>( lp.realm_prop_id, ST_X(lp.position) as position_x, ST_Y(lp.position) as position_y, - lp.scale, lp.dropped_by, lp.expires_at, lp.created_at, COALESCE(sp.name, rp.name) as prop_name, - COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, - lp.is_locked, - lp.locked_by + COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path FROM scene.loose_props lp LEFT JOIN server.props sp ON lp.server_prop_id = sp.id LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id @@ -84,7 +81,6 @@ pub async fn drop_prop_to_canvas<'e>( ) -> Result { // Single CTE that checks existence/droppability and performs the operation atomically. // Returns status flags plus the LooseProp data (if successful). - // Includes scale inherited from the source prop's default_scale. let result: Option<( bool, bool, @@ -95,7 +91,6 @@ pub async fn drop_prop_to_canvas<'e>( Option, Option, Option, - Option, Option, Option>, Option>, @@ -104,18 +99,9 @@ pub async fn drop_prop_to_canvas<'e>( )> = sqlx::query_as( r#" WITH item_info AS ( - SELECT - inv.id, - inv.is_droppable, - inv.server_prop_id, - inv.realm_prop_id, - inv.prop_name, - inv.prop_asset_path, - COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale - FROM auth.inventory inv - LEFT JOIN server.props sp ON inv.server_prop_id = sp.id - LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id - WHERE inv.id = $1 AND inv.user_id = $2 + SELECT id, is_droppable, server_prop_id, realm_prop_id, prop_name, prop_asset_path + FROM auth.inventory + WHERE id = $1 AND user_id = $2 ), deleted_item AS ( DELETE FROM auth.inventory @@ -128,7 +114,6 @@ pub async fn drop_prop_to_canvas<'e>( server_prop_id, realm_prop_id, position, - scale, dropped_by, expires_at ) @@ -137,7 +122,6 @@ pub async fn drop_prop_to_canvas<'e>( di.server_prop_id, di.realm_prop_id, public.make_virtual_point($4::real, $5::real), - (SELECT default_scale FROM item_info), $2, now() + interval '30 minutes' FROM deleted_item di @@ -148,7 +132,6 @@ pub async fn drop_prop_to_canvas<'e>( realm_prop_id, ST_X(position)::real as position_x, ST_Y(position)::real as position_y, - scale, dropped_by, expires_at, created_at @@ -163,7 +146,6 @@ pub async fn drop_prop_to_canvas<'e>( ip.realm_prop_id, ip.position_x, ip.position_y, - ip.scale, ip.dropped_by, ip.expires_at, ip.created_at, @@ -189,19 +171,19 @@ pub async fn drop_prop_to_canvas<'e>( "Unexpected error dropping prop to canvas".to_string(), )) } - Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _, _)) => { + Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => { // Item didn't exist Err(AppError::NotFound( "Inventory item not found or not owned by user".to_string(), )) } - Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => { + Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => { // Item existed but is not droppable Err(AppError::Forbidden( "This prop cannot be dropped - it is an essential prop".to_string(), )) } - Some((true, true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => { + Some((true, true, false, _, _, _, _, _, _, _, _, _, _, _)) => { // Item was droppable but delete failed (shouldn't happen) Err(AppError::Internal( "Unexpected error dropping prop to canvas".to_string(), @@ -217,7 +199,6 @@ pub async fn drop_prop_to_canvas<'e>( realm_prop_id, Some(position_x), Some(position_y), - Some(scale), dropped_by, Some(expires_at), Some(created_at), @@ -232,14 +213,11 @@ pub async fn drop_prop_to_canvas<'e>( realm_prop_id, position_x: position_x.into(), position_y: position_y.into(), - scale, dropped_by, expires_at: Some(expires_at), created_at, prop_name, prop_asset_path, - is_locked: false, - locked_by: None, }) } _ => { @@ -330,281 +308,11 @@ pub async fn pick_up_loose_prop<'e>( .bind(user_id) .fetch_optional(executor) .await? - .or_not_found("Loose prop (may have expired)")?; + .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; Ok(item) } -/// Update the scale of a loose prop. -/// -/// Server admins can update any loose prop. -/// Realm admins can update loose props in their realm. -pub async fn update_loose_prop_scale<'e>( - executor: impl PgExecutor<'e>, - loose_prop_id: Uuid, - scale: f32, -) -> Result { - // Validate scale range - if !(0.1..=10.0).contains(&scale) { - return Err(AppError::Validation( - "Scale must be between 0.1 and 10.0".to_string(), - )); - } - - let prop = sqlx::query_as::<_, LooseProp>( - r#" - WITH updated AS ( - UPDATE scene.loose_props - SET scale = $2 - WHERE id = $1 - AND (expires_at IS NULL OR expires_at > now()) - RETURNING - id, - instance_id as channel_id, - server_prop_id, - realm_prop_id, - ST_X(position) as position_x, - ST_Y(position) as position_y, - scale, - dropped_by, - expires_at, - created_at, - is_locked, - locked_by - ) - SELECT - u.id, - u.channel_id, - u.server_prop_id, - u.realm_prop_id, - u.position_x, - u.position_y, - u.scale, - u.dropped_by, - u.expires_at, - u.created_at, - COALESCE(sp.name, rp.name) as prop_name, - COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, - u.is_locked, - u.locked_by - FROM updated u - LEFT JOIN server.props sp ON u.server_prop_id = sp.id - LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id - "#, - ) - .bind(loose_prop_id) - .bind(scale) - .fetch_optional(executor) - .await? - .or_not_found("Loose prop (may have expired)")?; - - Ok(prop) -} - -/// Get a loose prop by ID. -pub async fn get_loose_prop_by_id<'e>( - executor: impl PgExecutor<'e>, - loose_prop_id: Uuid, -) -> Result, AppError> { - let prop = sqlx::query_as::<_, LooseProp>( - r#" - SELECT - lp.id, - lp.instance_id as channel_id, - lp.server_prop_id, - lp.realm_prop_id, - ST_X(lp.position) as position_x, - ST_Y(lp.position) as position_y, - lp.scale, - lp.dropped_by, - lp.expires_at, - lp.created_at, - COALESCE(sp.name, rp.name) as prop_name, - COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, - lp.is_locked, - lp.locked_by - FROM scene.loose_props lp - LEFT JOIN server.props sp ON lp.server_prop_id = sp.id - LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id - WHERE lp.id = $1 - AND (lp.expires_at IS NULL OR lp.expires_at > now()) - "#, - ) - .bind(loose_prop_id) - .fetch_optional(executor) - .await?; - - Ok(prop) -} - -/// Move a loose prop to a new position. -pub async fn move_loose_prop<'e>( - executor: impl PgExecutor<'e>, - loose_prop_id: Uuid, - x: f64, - y: f64, -) -> Result { - let prop = sqlx::query_as::<_, LooseProp>( - r#" - WITH updated AS ( - UPDATE scene.loose_props - SET position = public.make_virtual_point($2::real, $3::real) - WHERE id = $1 - AND (expires_at IS NULL OR expires_at > now()) - RETURNING - id, - instance_id as channel_id, - server_prop_id, - realm_prop_id, - ST_X(position) as position_x, - ST_Y(position) as position_y, - scale, - dropped_by, - expires_at, - created_at, - is_locked, - locked_by - ) - SELECT - u.id, - u.channel_id, - u.server_prop_id, - u.realm_prop_id, - u.position_x, - u.position_y, - u.scale, - u.dropped_by, - u.expires_at, - u.created_at, - COALESCE(sp.name, rp.name) as prop_name, - COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, - u.is_locked, - u.locked_by - FROM updated u - LEFT JOIN server.props sp ON u.server_prop_id = sp.id - LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id - "#, - ) - .bind(loose_prop_id) - .bind(x as f32) - .bind(y as f32) - .fetch_optional(executor) - .await? - .or_not_found("Loose prop (may have expired)")?; - - Ok(prop) -} - -/// Lock a loose prop (moderator only). -pub async fn lock_loose_prop<'e>( - executor: impl PgExecutor<'e>, - loose_prop_id: Uuid, - locked_by: Uuid, -) -> Result { - let prop = sqlx::query_as::<_, LooseProp>( - r#" - WITH updated AS ( - UPDATE scene.loose_props - SET is_locked = true, locked_by = $2 - WHERE id = $1 - AND (expires_at IS NULL OR expires_at > now()) - RETURNING - id, - instance_id as channel_id, - server_prop_id, - realm_prop_id, - ST_X(position) as position_x, - ST_Y(position) as position_y, - scale, - dropped_by, - expires_at, - created_at, - is_locked, - locked_by - ) - SELECT - u.id, - u.channel_id, - u.server_prop_id, - u.realm_prop_id, - u.position_x, - u.position_y, - u.scale, - u.dropped_by, - u.expires_at, - u.created_at, - COALESCE(sp.name, rp.name) as prop_name, - COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, - u.is_locked, - u.locked_by - FROM updated u - LEFT JOIN server.props sp ON u.server_prop_id = sp.id - LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id - "#, - ) - .bind(loose_prop_id) - .bind(locked_by) - .fetch_optional(executor) - .await? - .or_not_found("Loose prop (may have expired)")?; - - Ok(prop) -} - -/// Unlock a loose prop (moderator only). -pub async fn unlock_loose_prop<'e>( - executor: impl PgExecutor<'e>, - loose_prop_id: Uuid, -) -> Result { - let prop = sqlx::query_as::<_, LooseProp>( - r#" - WITH updated AS ( - UPDATE scene.loose_props - SET is_locked = false, locked_by = NULL - WHERE id = $1 - AND (expires_at IS NULL OR expires_at > now()) - RETURNING - id, - instance_id as channel_id, - server_prop_id, - realm_prop_id, - ST_X(position) as position_x, - ST_Y(position) as position_y, - scale, - dropped_by, - expires_at, - created_at, - is_locked, - locked_by - ) - SELECT - u.id, - u.channel_id, - u.server_prop_id, - u.realm_prop_id, - u.position_x, - u.position_y, - u.scale, - u.dropped_by, - u.expires_at, - u.created_at, - COALESCE(sp.name, rp.name) as prop_name, - COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, - u.is_locked, - u.locked_by - FROM updated u - LEFT JOIN server.props sp ON u.server_prop_id = sp.id - LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id - "#, - ) - .bind(loose_prop_id) - .fetch_optional(executor) - .await? - .or_not_found("Loose prop (may have expired)")?; - - Ok(prop) -} - /// Delete expired loose props. /// /// Returns the number of props deleted. diff --git a/crates/chattyness-db/src/queries/props.rs b/crates/chattyness-db/src/queries/props.rs index 076dc2b..e4b7f88 100644 --- a/crates/chattyness-db/src/queries/props.rs +++ b/crates/chattyness-db/src/queries/props.rs @@ -48,7 +48,6 @@ pub async fn get_server_prop_by_id<'e>( default_layer, default_emotion, default_position, - default_scale, is_unique, is_transferable, is_portable, @@ -117,23 +116,20 @@ pub async fn create_server_prop<'e>( let is_droppable = req.droppable.unwrap_or(true); let is_public = req.public.unwrap_or(false); - let default_scale = req.default_scale.unwrap_or(1.0); let prop = sqlx::query_as::<_, ServerProp>( r#" INSERT INTO server.props ( name, slug, description, tags, asset_path, default_layer, default_emotion, default_position, - default_scale, is_droppable, is_public, created_by ) VALUES ( $1, $2, $3, $4, $5, $6::server.avatar_layer, $7::server.emotion_state, $8, - $9, - $10, $11, - $12 + $9, $10, + $11 ) RETURNING id, @@ -146,7 +142,6 @@ pub async fn create_server_prop<'e>( default_layer, default_emotion, default_position, - default_scale, is_unique, is_transferable, is_portable, @@ -168,7 +163,6 @@ pub async fn create_server_prop<'e>( .bind(&default_layer) .bind(&default_emotion) .bind(default_position) - .bind(default_scale) .bind(is_droppable) .bind(is_public) .bind(created_by) @@ -213,23 +207,20 @@ pub async fn upsert_server_prop<'e>( let is_droppable = req.droppable.unwrap_or(true); let is_public = req.public.unwrap_or(false); - let default_scale = req.default_scale.unwrap_or(1.0); let prop = sqlx::query_as::<_, ServerProp>( r#" INSERT INTO server.props ( name, slug, description, tags, asset_path, default_layer, default_emotion, default_position, - default_scale, is_droppable, is_public, created_by ) VALUES ( $1, $2, $3, $4, $5, $6::server.avatar_layer, $7::server.emotion_state, $8, - $9, - $10, $11, - $12 + $9, $10, + $11 ) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, @@ -239,7 +230,6 @@ pub async fn upsert_server_prop<'e>( default_layer = EXCLUDED.default_layer, default_emotion = EXCLUDED.default_emotion, default_position = EXCLUDED.default_position, - default_scale = EXCLUDED.default_scale, is_droppable = EXCLUDED.is_droppable, is_public = EXCLUDED.is_public, updated_at = now() @@ -254,7 +244,6 @@ pub async fn upsert_server_prop<'e>( default_layer, default_emotion, default_position, - default_scale, is_unique, is_transferable, is_portable, @@ -276,7 +265,6 @@ pub async fn upsert_server_prop<'e>( .bind(&default_layer) .bind(&default_emotion) .bind(default_position) - .bind(default_scale) .bind(is_droppable) .bind(is_public) .bind(created_by) diff --git a/crates/chattyness-db/src/queries/realm_avatars.rs b/crates/chattyness-db/src/queries/realm_avatars.rs index 95c1a8a..359d227 100644 --- a/crates/chattyness-db/src/queries/realm_avatars.rs +++ b/crates/chattyness-db/src/queries/realm_avatars.rs @@ -3,22 +3,15 @@ //! Realm avatars are pre-configured avatar configurations specific to a realm. //! They reference realm.props directly (not inventory items). +use std::collections::HashMap; + use chrono::{Duration, Utc}; use sqlx::PgExecutor; use uuid::Uuid; -use crate::extract_avatar_slots; -use crate::models::{AvatarRenderData, EmotionState, RealmAvatar, RealmAvatarWithPaths}; -use crate::queries::avatar_common::{ - avatar_paths_join_clause, avatar_paths_select_clause, build_prop_map, - resolve_slots_to_render_data, -}; +use crate::models::{AvatarRenderData, EmotionState, RealmAvatar}; use chattyness_error::AppError; -// ============================================================================= -// Basic Queries -// ============================================================================= - /// Get a realm avatar by slug within a realm. pub async fn get_realm_avatar_by_slug<'e>( executor: impl PgExecutor<'e>, @@ -79,9 +72,7 @@ pub async fn list_public_realm_avatars<'e>( Ok(avatars) } -// ============================================================================= -// Avatar with Paths Queries -// ============================================================================= +use crate::models::RealmAvatarWithPaths; /// Row type for realm avatar with paths query. #[derive(Debug, sqlx::FromRow)] @@ -119,7 +110,7 @@ struct RealmAvatarWithPathsRow { accessories_6: Option, accessories_7: Option, accessories_8: Option, - // Happy emotion layer paths + // Happy emotion layer paths (e1 - more inviting for store display) emotion_0: Option, emotion_1: Option, emotion_2: Option, @@ -162,51 +153,235 @@ impl From for RealmAvatarWithPaths { } /// List all active public realm avatars with resolved asset paths. +/// +/// Joins with the props table to resolve prop UUIDs to asset paths, +/// suitable for client-side rendering without additional lookups. pub async fn list_public_realm_avatars_with_paths<'e>( executor: impl PgExecutor<'e>, realm_id: Uuid, ) -> Result, AppError> { - let join_clause = avatar_paths_join_clause("realm.props"); - let query = format!( + let rows = sqlx::query_as::<_, RealmAvatarWithPathsRow>( r#" SELECT - {} + a.id, + a.name, + a.description, + -- Skin layer + p_skin_0.asset_path AS skin_0, + p_skin_1.asset_path AS skin_1, + p_skin_2.asset_path AS skin_2, + p_skin_3.asset_path AS skin_3, + p_skin_4.asset_path AS skin_4, + p_skin_5.asset_path AS skin_5, + p_skin_6.asset_path AS skin_6, + p_skin_7.asset_path AS skin_7, + p_skin_8.asset_path AS skin_8, + -- Clothes layer + p_clothes_0.asset_path AS clothes_0, + p_clothes_1.asset_path AS clothes_1, + p_clothes_2.asset_path AS clothes_2, + p_clothes_3.asset_path AS clothes_3, + p_clothes_4.asset_path AS clothes_4, + p_clothes_5.asset_path AS clothes_5, + p_clothes_6.asset_path AS clothes_6, + p_clothes_7.asset_path AS clothes_7, + p_clothes_8.asset_path AS clothes_8, + -- Accessories layer + p_acc_0.asset_path AS accessories_0, + p_acc_1.asset_path AS accessories_1, + p_acc_2.asset_path AS accessories_2, + p_acc_3.asset_path AS accessories_3, + p_acc_4.asset_path AS accessories_4, + p_acc_5.asset_path AS accessories_5, + p_acc_6.asset_path AS accessories_6, + p_acc_7.asset_path AS accessories_7, + p_acc_8.asset_path AS accessories_8, + -- Happy emotion layer (e1 - more inviting for store display) + p_emo_0.asset_path AS emotion_0, + p_emo_1.asset_path AS emotion_1, + p_emo_2.asset_path AS emotion_2, + p_emo_3.asset_path AS emotion_3, + p_emo_4.asset_path AS emotion_4, + p_emo_5.asset_path AS emotion_5, + p_emo_6.asset_path AS emotion_6, + p_emo_7.asset_path AS emotion_7, + p_emo_8.asset_path AS emotion_8 FROM realm.avatars a - {} + -- Skin layer joins + LEFT JOIN realm.props p_skin_0 ON a.l_skin_0 = p_skin_0.id + LEFT JOIN realm.props p_skin_1 ON a.l_skin_1 = p_skin_1.id + LEFT JOIN realm.props p_skin_2 ON a.l_skin_2 = p_skin_2.id + LEFT JOIN realm.props p_skin_3 ON a.l_skin_3 = p_skin_3.id + LEFT JOIN realm.props p_skin_4 ON a.l_skin_4 = p_skin_4.id + LEFT JOIN realm.props p_skin_5 ON a.l_skin_5 = p_skin_5.id + LEFT JOIN realm.props p_skin_6 ON a.l_skin_6 = p_skin_6.id + LEFT JOIN realm.props p_skin_7 ON a.l_skin_7 = p_skin_7.id + LEFT JOIN realm.props p_skin_8 ON a.l_skin_8 = p_skin_8.id + -- Clothes layer joins + LEFT JOIN realm.props p_clothes_0 ON a.l_clothes_0 = p_clothes_0.id + LEFT JOIN realm.props p_clothes_1 ON a.l_clothes_1 = p_clothes_1.id + LEFT JOIN realm.props p_clothes_2 ON a.l_clothes_2 = p_clothes_2.id + LEFT JOIN realm.props p_clothes_3 ON a.l_clothes_3 = p_clothes_3.id + LEFT JOIN realm.props p_clothes_4 ON a.l_clothes_4 = p_clothes_4.id + LEFT JOIN realm.props p_clothes_5 ON a.l_clothes_5 = p_clothes_5.id + LEFT JOIN realm.props p_clothes_6 ON a.l_clothes_6 = p_clothes_6.id + LEFT JOIN realm.props p_clothes_7 ON a.l_clothes_7 = p_clothes_7.id + LEFT JOIN realm.props p_clothes_8 ON a.l_clothes_8 = p_clothes_8.id + -- Accessories layer joins + LEFT JOIN realm.props p_acc_0 ON a.l_accessories_0 = p_acc_0.id + LEFT JOIN realm.props p_acc_1 ON a.l_accessories_1 = p_acc_1.id + LEFT JOIN realm.props p_acc_2 ON a.l_accessories_2 = p_acc_2.id + LEFT JOIN realm.props p_acc_3 ON a.l_accessories_3 = p_acc_3.id + LEFT JOIN realm.props p_acc_4 ON a.l_accessories_4 = p_acc_4.id + LEFT JOIN realm.props p_acc_5 ON a.l_accessories_5 = p_acc_5.id + LEFT JOIN realm.props p_acc_6 ON a.l_accessories_6 = p_acc_6.id + LEFT JOIN realm.props p_acc_7 ON a.l_accessories_7 = p_acc_7.id + LEFT JOIN realm.props p_acc_8 ON a.l_accessories_8 = p_acc_8.id + -- Happy emotion layer joins (e1 - more inviting for store display) + LEFT JOIN realm.props p_emo_0 ON a.e_happy_0 = p_emo_0.id + LEFT JOIN realm.props p_emo_1 ON a.e_happy_1 = p_emo_1.id + LEFT JOIN realm.props p_emo_2 ON a.e_happy_2 = p_emo_2.id + LEFT JOIN realm.props p_emo_3 ON a.e_happy_3 = p_emo_3.id + LEFT JOIN realm.props p_emo_4 ON a.e_happy_4 = p_emo_4.id + LEFT JOIN realm.props p_emo_5 ON a.e_happy_5 = p_emo_5.id + LEFT JOIN realm.props p_emo_6 ON a.e_happy_6 = p_emo_6.id + LEFT JOIN realm.props p_emo_7 ON a.e_happy_7 = p_emo_7.id + LEFT JOIN realm.props p_emo_8 ON a.e_happy_8 = p_emo_8.id WHERE a.realm_id = $1 AND a.is_active = true AND a.is_public = true ORDER BY a.name ASC "#, - avatar_paths_select_clause(), - join_clause - ); - - let rows = sqlx::query_as::<_, RealmAvatarWithPathsRow>(&query) - .bind(realm_id) - .fetch_all(executor) - .await?; + ) + .bind(realm_id) + .fetch_all(executor) + .await?; Ok(rows.into_iter().map(RealmAvatarWithPaths::from).collect()) } -// ============================================================================= -// Render Data Resolution -// ============================================================================= +/// Row type for prop asset lookup. +#[derive(Debug, sqlx::FromRow)] +struct PropAssetRow { + id: Uuid, + asset_path: String, +} /// Resolve a realm avatar to render data. +/// Joins the avatar's prop UUIDs with realm.props to get asset paths. pub async fn resolve_realm_avatar_to_render_data<'e>( executor: impl PgExecutor<'e>, avatar: &RealmAvatar, current_emotion: EmotionState, ) -> Result { - let slots = extract_avatar_slots!(avatar); - let prop_ids = slots.collect_render_prop_ids(current_emotion); - let prop_map = build_prop_map(executor, &prop_ids, "realm.props").await?; - Ok(resolve_slots_to_render_data(avatar.id, &slots, current_emotion, &prop_map)) -} + // Collect all non-null prop UUIDs + let mut prop_ids: Vec = Vec::new(); -// ============================================================================= -// Forced Avatar Management -// ============================================================================= + // Content layers + for id in [ + avatar.l_skin_0, avatar.l_skin_1, avatar.l_skin_2, + avatar.l_skin_3, avatar.l_skin_4, avatar.l_skin_5, + avatar.l_skin_6, avatar.l_skin_7, avatar.l_skin_8, + avatar.l_clothes_0, avatar.l_clothes_1, avatar.l_clothes_2, + avatar.l_clothes_3, avatar.l_clothes_4, avatar.l_clothes_5, + avatar.l_clothes_6, avatar.l_clothes_7, avatar.l_clothes_8, + avatar.l_accessories_0, avatar.l_accessories_1, avatar.l_accessories_2, + avatar.l_accessories_3, avatar.l_accessories_4, avatar.l_accessories_5, + avatar.l_accessories_6, avatar.l_accessories_7, avatar.l_accessories_8, + ].iter().flatten() { + prop_ids.push(*id); + } + + // Get emotion layer slots based on current emotion + let emotion_slots: [Option; 9] = match current_emotion { + EmotionState::Neutral => [avatar.e_neutral_0, avatar.e_neutral_1, avatar.e_neutral_2, + avatar.e_neutral_3, avatar.e_neutral_4, avatar.e_neutral_5, + avatar.e_neutral_6, avatar.e_neutral_7, avatar.e_neutral_8], + EmotionState::Happy => [avatar.e_happy_0, avatar.e_happy_1, avatar.e_happy_2, + avatar.e_happy_3, avatar.e_happy_4, avatar.e_happy_5, + avatar.e_happy_6, avatar.e_happy_7, avatar.e_happy_8], + EmotionState::Sad => [avatar.e_sad_0, avatar.e_sad_1, avatar.e_sad_2, + avatar.e_sad_3, avatar.e_sad_4, avatar.e_sad_5, + avatar.e_sad_6, avatar.e_sad_7, avatar.e_sad_8], + EmotionState::Angry => [avatar.e_angry_0, avatar.e_angry_1, avatar.e_angry_2, + avatar.e_angry_3, avatar.e_angry_4, avatar.e_angry_5, + avatar.e_angry_6, avatar.e_angry_7, avatar.e_angry_8], + EmotionState::Surprised => [avatar.e_surprised_0, avatar.e_surprised_1, avatar.e_surprised_2, + avatar.e_surprised_3, avatar.e_surprised_4, avatar.e_surprised_5, + avatar.e_surprised_6, avatar.e_surprised_7, avatar.e_surprised_8], + EmotionState::Thinking => [avatar.e_thinking_0, avatar.e_thinking_1, avatar.e_thinking_2, + avatar.e_thinking_3, avatar.e_thinking_4, avatar.e_thinking_5, + avatar.e_thinking_6, avatar.e_thinking_7, avatar.e_thinking_8], + EmotionState::Laughing => [avatar.e_laughing_0, avatar.e_laughing_1, avatar.e_laughing_2, + avatar.e_laughing_3, avatar.e_laughing_4, avatar.e_laughing_5, + avatar.e_laughing_6, avatar.e_laughing_7, avatar.e_laughing_8], + EmotionState::Crying => [avatar.e_crying_0, avatar.e_crying_1, avatar.e_crying_2, + avatar.e_crying_3, avatar.e_crying_4, avatar.e_crying_5, + avatar.e_crying_6, avatar.e_crying_7, avatar.e_crying_8], + EmotionState::Love => [avatar.e_love_0, avatar.e_love_1, avatar.e_love_2, + avatar.e_love_3, avatar.e_love_4, avatar.e_love_5, + avatar.e_love_6, avatar.e_love_7, avatar.e_love_8], + EmotionState::Confused => [avatar.e_confused_0, avatar.e_confused_1, avatar.e_confused_2, + avatar.e_confused_3, avatar.e_confused_4, avatar.e_confused_5, + avatar.e_confused_6, avatar.e_confused_7, avatar.e_confused_8], + EmotionState::Sleeping => [avatar.e_sleeping_0, avatar.e_sleeping_1, avatar.e_sleeping_2, + avatar.e_sleeping_3, avatar.e_sleeping_4, avatar.e_sleeping_5, + avatar.e_sleeping_6, avatar.e_sleeping_7, avatar.e_sleeping_8], + EmotionState::Wink => [avatar.e_wink_0, avatar.e_wink_1, avatar.e_wink_2, + avatar.e_wink_3, avatar.e_wink_4, avatar.e_wink_5, + avatar.e_wink_6, avatar.e_wink_7, avatar.e_wink_8], + }; + + for id in emotion_slots.iter().flatten() { + prop_ids.push(*id); + } + + // Bulk lookup all prop asset paths from realm.props + let prop_map: HashMap = if prop_ids.is_empty() { + HashMap::new() + } else { + let rows = sqlx::query_as::<_, PropAssetRow>( + r#" + SELECT id, asset_path + FROM realm.props + WHERE id = ANY($1) + "#, + ) + .bind(&prop_ids) + .fetch_all(executor) + .await?; + + rows.into_iter().map(|r| (r.id, r.asset_path)).collect() + }; + + // Helper to look up path + let get_path = |id: Option| -> Option { + id.and_then(|id| prop_map.get(&id).cloned()) + }; + + Ok(AvatarRenderData { + avatar_id: avatar.id, + current_emotion, + skin_layer: [ + get_path(avatar.l_skin_0), get_path(avatar.l_skin_1), get_path(avatar.l_skin_2), + get_path(avatar.l_skin_3), get_path(avatar.l_skin_4), get_path(avatar.l_skin_5), + get_path(avatar.l_skin_6), get_path(avatar.l_skin_7), get_path(avatar.l_skin_8), + ], + clothes_layer: [ + get_path(avatar.l_clothes_0), get_path(avatar.l_clothes_1), get_path(avatar.l_clothes_2), + get_path(avatar.l_clothes_3), get_path(avatar.l_clothes_4), get_path(avatar.l_clothes_5), + get_path(avatar.l_clothes_6), get_path(avatar.l_clothes_7), get_path(avatar.l_clothes_8), + ], + accessories_layer: [ + get_path(avatar.l_accessories_0), get_path(avatar.l_accessories_1), get_path(avatar.l_accessories_2), + get_path(avatar.l_accessories_3), get_path(avatar.l_accessories_4), get_path(avatar.l_accessories_5), + get_path(avatar.l_accessories_6), get_path(avatar.l_accessories_7), get_path(avatar.l_accessories_8), + ], + emotion_layer: [ + get_path(emotion_slots[0]), get_path(emotion_slots[1]), get_path(emotion_slots[2]), + get_path(emotion_slots[3]), get_path(emotion_slots[4]), get_path(emotion_slots[5]), + get_path(emotion_slots[6]), get_path(emotion_slots[7]), get_path(emotion_slots[8]), + ], + }) +} /// Apply a forced realm avatar to a user. pub async fn apply_forced_realm_avatar<'e>( @@ -401,65 +576,155 @@ pub async fn create_realm_avatar<'e>( .bind(&req.thumbnail_path) .bind(created_by) // Skin layer - .bind(req.l_skin_0).bind(req.l_skin_1).bind(req.l_skin_2) - .bind(req.l_skin_3).bind(req.l_skin_4).bind(req.l_skin_5) - .bind(req.l_skin_6).bind(req.l_skin_7).bind(req.l_skin_8) + .bind(req.l_skin_0) + .bind(req.l_skin_1) + .bind(req.l_skin_2) + .bind(req.l_skin_3) + .bind(req.l_skin_4) + .bind(req.l_skin_5) + .bind(req.l_skin_6) + .bind(req.l_skin_7) + .bind(req.l_skin_8) // Clothes layer - .bind(req.l_clothes_0).bind(req.l_clothes_1).bind(req.l_clothes_2) - .bind(req.l_clothes_3).bind(req.l_clothes_4).bind(req.l_clothes_5) - .bind(req.l_clothes_6).bind(req.l_clothes_7).bind(req.l_clothes_8) + .bind(req.l_clothes_0) + .bind(req.l_clothes_1) + .bind(req.l_clothes_2) + .bind(req.l_clothes_3) + .bind(req.l_clothes_4) + .bind(req.l_clothes_5) + .bind(req.l_clothes_6) + .bind(req.l_clothes_7) + .bind(req.l_clothes_8) // Accessories layer - .bind(req.l_accessories_0).bind(req.l_accessories_1).bind(req.l_accessories_2) - .bind(req.l_accessories_3).bind(req.l_accessories_4).bind(req.l_accessories_5) - .bind(req.l_accessories_6).bind(req.l_accessories_7).bind(req.l_accessories_8) + .bind(req.l_accessories_0) + .bind(req.l_accessories_1) + .bind(req.l_accessories_2) + .bind(req.l_accessories_3) + .bind(req.l_accessories_4) + .bind(req.l_accessories_5) + .bind(req.l_accessories_6) + .bind(req.l_accessories_7) + .bind(req.l_accessories_8) // Neutral emotion - .bind(req.e_neutral_0).bind(req.e_neutral_1).bind(req.e_neutral_2) - .bind(req.e_neutral_3).bind(req.e_neutral_4).bind(req.e_neutral_5) - .bind(req.e_neutral_6).bind(req.e_neutral_7).bind(req.e_neutral_8) + .bind(req.e_neutral_0) + .bind(req.e_neutral_1) + .bind(req.e_neutral_2) + .bind(req.e_neutral_3) + .bind(req.e_neutral_4) + .bind(req.e_neutral_5) + .bind(req.e_neutral_6) + .bind(req.e_neutral_7) + .bind(req.e_neutral_8) // Happy emotion - .bind(req.e_happy_0).bind(req.e_happy_1).bind(req.e_happy_2) - .bind(req.e_happy_3).bind(req.e_happy_4).bind(req.e_happy_5) - .bind(req.e_happy_6).bind(req.e_happy_7).bind(req.e_happy_8) + .bind(req.e_happy_0) + .bind(req.e_happy_1) + .bind(req.e_happy_2) + .bind(req.e_happy_3) + .bind(req.e_happy_4) + .bind(req.e_happy_5) + .bind(req.e_happy_6) + .bind(req.e_happy_7) + .bind(req.e_happy_8) // Sad emotion - .bind(req.e_sad_0).bind(req.e_sad_1).bind(req.e_sad_2) - .bind(req.e_sad_3).bind(req.e_sad_4).bind(req.e_sad_5) - .bind(req.e_sad_6).bind(req.e_sad_7).bind(req.e_sad_8) + .bind(req.e_sad_0) + .bind(req.e_sad_1) + .bind(req.e_sad_2) + .bind(req.e_sad_3) + .bind(req.e_sad_4) + .bind(req.e_sad_5) + .bind(req.e_sad_6) + .bind(req.e_sad_7) + .bind(req.e_sad_8) // Angry emotion - .bind(req.e_angry_0).bind(req.e_angry_1).bind(req.e_angry_2) - .bind(req.e_angry_3).bind(req.e_angry_4).bind(req.e_angry_5) - .bind(req.e_angry_6).bind(req.e_angry_7).bind(req.e_angry_8) + .bind(req.e_angry_0) + .bind(req.e_angry_1) + .bind(req.e_angry_2) + .bind(req.e_angry_3) + .bind(req.e_angry_4) + .bind(req.e_angry_5) + .bind(req.e_angry_6) + .bind(req.e_angry_7) + .bind(req.e_angry_8) // Surprised emotion - .bind(req.e_surprised_0).bind(req.e_surprised_1).bind(req.e_surprised_2) - .bind(req.e_surprised_3).bind(req.e_surprised_4).bind(req.e_surprised_5) - .bind(req.e_surprised_6).bind(req.e_surprised_7).bind(req.e_surprised_8) + .bind(req.e_surprised_0) + .bind(req.e_surprised_1) + .bind(req.e_surprised_2) + .bind(req.e_surprised_3) + .bind(req.e_surprised_4) + .bind(req.e_surprised_5) + .bind(req.e_surprised_6) + .bind(req.e_surprised_7) + .bind(req.e_surprised_8) // Thinking emotion - .bind(req.e_thinking_0).bind(req.e_thinking_1).bind(req.e_thinking_2) - .bind(req.e_thinking_3).bind(req.e_thinking_4).bind(req.e_thinking_5) - .bind(req.e_thinking_6).bind(req.e_thinking_7).bind(req.e_thinking_8) + .bind(req.e_thinking_0) + .bind(req.e_thinking_1) + .bind(req.e_thinking_2) + .bind(req.e_thinking_3) + .bind(req.e_thinking_4) + .bind(req.e_thinking_5) + .bind(req.e_thinking_6) + .bind(req.e_thinking_7) + .bind(req.e_thinking_8) // Laughing emotion - .bind(req.e_laughing_0).bind(req.e_laughing_1).bind(req.e_laughing_2) - .bind(req.e_laughing_3).bind(req.e_laughing_4).bind(req.e_laughing_5) - .bind(req.e_laughing_6).bind(req.e_laughing_7).bind(req.e_laughing_8) + .bind(req.e_laughing_0) + .bind(req.e_laughing_1) + .bind(req.e_laughing_2) + .bind(req.e_laughing_3) + .bind(req.e_laughing_4) + .bind(req.e_laughing_5) + .bind(req.e_laughing_6) + .bind(req.e_laughing_7) + .bind(req.e_laughing_8) // Crying emotion - .bind(req.e_crying_0).bind(req.e_crying_1).bind(req.e_crying_2) - .bind(req.e_crying_3).bind(req.e_crying_4).bind(req.e_crying_5) - .bind(req.e_crying_6).bind(req.e_crying_7).bind(req.e_crying_8) + .bind(req.e_crying_0) + .bind(req.e_crying_1) + .bind(req.e_crying_2) + .bind(req.e_crying_3) + .bind(req.e_crying_4) + .bind(req.e_crying_5) + .bind(req.e_crying_6) + .bind(req.e_crying_7) + .bind(req.e_crying_8) // Love emotion - .bind(req.e_love_0).bind(req.e_love_1).bind(req.e_love_2) - .bind(req.e_love_3).bind(req.e_love_4).bind(req.e_love_5) - .bind(req.e_love_6).bind(req.e_love_7).bind(req.e_love_8) + .bind(req.e_love_0) + .bind(req.e_love_1) + .bind(req.e_love_2) + .bind(req.e_love_3) + .bind(req.e_love_4) + .bind(req.e_love_5) + .bind(req.e_love_6) + .bind(req.e_love_7) + .bind(req.e_love_8) // Confused emotion - .bind(req.e_confused_0).bind(req.e_confused_1).bind(req.e_confused_2) - .bind(req.e_confused_3).bind(req.e_confused_4).bind(req.e_confused_5) - .bind(req.e_confused_6).bind(req.e_confused_7).bind(req.e_confused_8) + .bind(req.e_confused_0) + .bind(req.e_confused_1) + .bind(req.e_confused_2) + .bind(req.e_confused_3) + .bind(req.e_confused_4) + .bind(req.e_confused_5) + .bind(req.e_confused_6) + .bind(req.e_confused_7) + .bind(req.e_confused_8) // Sleeping emotion - .bind(req.e_sleeping_0).bind(req.e_sleeping_1).bind(req.e_sleeping_2) - .bind(req.e_sleeping_3).bind(req.e_sleeping_4).bind(req.e_sleeping_5) - .bind(req.e_sleeping_6).bind(req.e_sleeping_7).bind(req.e_sleeping_8) + .bind(req.e_sleeping_0) + .bind(req.e_sleeping_1) + .bind(req.e_sleeping_2) + .bind(req.e_sleeping_3) + .bind(req.e_sleeping_4) + .bind(req.e_sleeping_5) + .bind(req.e_sleeping_6) + .bind(req.e_sleeping_7) + .bind(req.e_sleeping_8) // Wink emotion - .bind(req.e_wink_0).bind(req.e_wink_1).bind(req.e_wink_2) - .bind(req.e_wink_3).bind(req.e_wink_4).bind(req.e_wink_5) - .bind(req.e_wink_6).bind(req.e_wink_7).bind(req.e_wink_8) + .bind(req.e_wink_0) + .bind(req.e_wink_1) + .bind(req.e_wink_2) + .bind(req.e_wink_3) + .bind(req.e_wink_4) + .bind(req.e_wink_5) + .bind(req.e_wink_6) + .bind(req.e_wink_7) + .bind(req.e_wink_8) .fetch_one(executor) .await?; @@ -480,51 +745,141 @@ pub async fn update_realm_avatar<'e>( is_public = COALESCE($4, is_public), is_active = COALESCE($5, is_active), thumbnail_path = COALESCE($6, thumbnail_path), - l_skin_0 = COALESCE($7, l_skin_0), l_skin_1 = COALESCE($8, l_skin_1), l_skin_2 = COALESCE($9, l_skin_2), - l_skin_3 = COALESCE($10, l_skin_3), l_skin_4 = COALESCE($11, l_skin_4), l_skin_5 = COALESCE($12, l_skin_5), - l_skin_6 = COALESCE($13, l_skin_6), l_skin_7 = COALESCE($14, l_skin_7), l_skin_8 = COALESCE($15, l_skin_8), - l_clothes_0 = COALESCE($16, l_clothes_0), l_clothes_1 = COALESCE($17, l_clothes_1), l_clothes_2 = COALESCE($18, l_clothes_2), - l_clothes_3 = COALESCE($19, l_clothes_3), l_clothes_4 = COALESCE($20, l_clothes_4), l_clothes_5 = COALESCE($21, l_clothes_5), - l_clothes_6 = COALESCE($22, l_clothes_6), l_clothes_7 = COALESCE($23, l_clothes_7), l_clothes_8 = COALESCE($24, l_clothes_8), - l_accessories_0 = COALESCE($25, l_accessories_0), l_accessories_1 = COALESCE($26, l_accessories_1), l_accessories_2 = COALESCE($27, l_accessories_2), - l_accessories_3 = COALESCE($28, l_accessories_3), l_accessories_4 = COALESCE($29, l_accessories_4), l_accessories_5 = COALESCE($30, l_accessories_5), - l_accessories_6 = COALESCE($31, l_accessories_6), l_accessories_7 = COALESCE($32, l_accessories_7), l_accessories_8 = COALESCE($33, l_accessories_8), - e_neutral_0 = COALESCE($34, e_neutral_0), e_neutral_1 = COALESCE($35, e_neutral_1), e_neutral_2 = COALESCE($36, e_neutral_2), - e_neutral_3 = COALESCE($37, e_neutral_3), e_neutral_4 = COALESCE($38, e_neutral_4), e_neutral_5 = COALESCE($39, e_neutral_5), - e_neutral_6 = COALESCE($40, e_neutral_6), e_neutral_7 = COALESCE($41, e_neutral_7), e_neutral_8 = COALESCE($42, e_neutral_8), - e_happy_0 = COALESCE($43, e_happy_0), e_happy_1 = COALESCE($44, e_happy_1), e_happy_2 = COALESCE($45, e_happy_2), - e_happy_3 = COALESCE($46, e_happy_3), e_happy_4 = COALESCE($47, e_happy_4), e_happy_5 = COALESCE($48, e_happy_5), - e_happy_6 = COALESCE($49, e_happy_6), e_happy_7 = COALESCE($50, e_happy_7), e_happy_8 = COALESCE($51, e_happy_8), - e_sad_0 = COALESCE($52, e_sad_0), e_sad_1 = COALESCE($53, e_sad_1), e_sad_2 = COALESCE($54, e_sad_2), - e_sad_3 = COALESCE($55, e_sad_3), e_sad_4 = COALESCE($56, e_sad_4), e_sad_5 = COALESCE($57, e_sad_5), - e_sad_6 = COALESCE($58, e_sad_6), e_sad_7 = COALESCE($59, e_sad_7), e_sad_8 = COALESCE($60, e_sad_8), - e_angry_0 = COALESCE($61, e_angry_0), e_angry_1 = COALESCE($62, e_angry_1), e_angry_2 = COALESCE($63, e_angry_2), - e_angry_3 = COALESCE($64, e_angry_3), e_angry_4 = COALESCE($65, e_angry_4), e_angry_5 = COALESCE($66, e_angry_5), - e_angry_6 = COALESCE($67, e_angry_6), e_angry_7 = COALESCE($68, e_angry_7), e_angry_8 = COALESCE($69, e_angry_8), - e_surprised_0 = COALESCE($70, e_surprised_0), e_surprised_1 = COALESCE($71, e_surprised_1), e_surprised_2 = COALESCE($72, e_surprised_2), - e_surprised_3 = COALESCE($73, e_surprised_3), e_surprised_4 = COALESCE($74, e_surprised_4), e_surprised_5 = COALESCE($75, e_surprised_5), - e_surprised_6 = COALESCE($76, e_surprised_6), e_surprised_7 = COALESCE($77, e_surprised_7), e_surprised_8 = COALESCE($78, e_surprised_8), - e_thinking_0 = COALESCE($79, e_thinking_0), e_thinking_1 = COALESCE($80, e_thinking_1), e_thinking_2 = COALESCE($81, e_thinking_2), - e_thinking_3 = COALESCE($82, e_thinking_3), e_thinking_4 = COALESCE($83, e_thinking_4), e_thinking_5 = COALESCE($84, e_thinking_5), - e_thinking_6 = COALESCE($85, e_thinking_6), e_thinking_7 = COALESCE($86, e_thinking_7), e_thinking_8 = COALESCE($87, e_thinking_8), - e_laughing_0 = COALESCE($88, e_laughing_0), e_laughing_1 = COALESCE($89, e_laughing_1), e_laughing_2 = COALESCE($90, e_laughing_2), - e_laughing_3 = COALESCE($91, e_laughing_3), e_laughing_4 = COALESCE($92, e_laughing_4), e_laughing_5 = COALESCE($93, e_laughing_5), - e_laughing_6 = COALESCE($94, e_laughing_6), e_laughing_7 = COALESCE($95, e_laughing_7), e_laughing_8 = COALESCE($96, e_laughing_8), - e_crying_0 = COALESCE($97, e_crying_0), e_crying_1 = COALESCE($98, e_crying_1), e_crying_2 = COALESCE($99, e_crying_2), - e_crying_3 = COALESCE($100, e_crying_3), e_crying_4 = COALESCE($101, e_crying_4), e_crying_5 = COALESCE($102, e_crying_5), - e_crying_6 = COALESCE($103, e_crying_6), e_crying_7 = COALESCE($104, e_crying_7), e_crying_8 = COALESCE($105, e_crying_8), - e_love_0 = COALESCE($106, e_love_0), e_love_1 = COALESCE($107, e_love_1), e_love_2 = COALESCE($108, e_love_2), - e_love_3 = COALESCE($109, e_love_3), e_love_4 = COALESCE($110, e_love_4), e_love_5 = COALESCE($111, e_love_5), - e_love_6 = COALESCE($112, e_love_6), e_love_7 = COALESCE($113, e_love_7), e_love_8 = COALESCE($114, e_love_8), - e_confused_0 = COALESCE($115, e_confused_0), e_confused_1 = COALESCE($116, e_confused_1), e_confused_2 = COALESCE($117, e_confused_2), - e_confused_3 = COALESCE($118, e_confused_3), e_confused_4 = COALESCE($119, e_confused_4), e_confused_5 = COALESCE($120, e_confused_5), - e_confused_6 = COALESCE($121, e_confused_6), e_confused_7 = COALESCE($122, e_confused_7), e_confused_8 = COALESCE($123, e_confused_8), - e_sleeping_0 = COALESCE($124, e_sleeping_0), e_sleeping_1 = COALESCE($125, e_sleeping_1), e_sleeping_2 = COALESCE($126, e_sleeping_2), - e_sleeping_3 = COALESCE($127, e_sleeping_3), e_sleeping_4 = COALESCE($128, e_sleeping_4), e_sleeping_5 = COALESCE($129, e_sleeping_5), - e_sleeping_6 = COALESCE($130, e_sleeping_6), e_sleeping_7 = COALESCE($131, e_sleeping_7), e_sleeping_8 = COALESCE($132, e_sleeping_8), - e_wink_0 = COALESCE($133, e_wink_0), e_wink_1 = COALESCE($134, e_wink_1), e_wink_2 = COALESCE($135, e_wink_2), - e_wink_3 = COALESCE($136, e_wink_3), e_wink_4 = COALESCE($137, e_wink_4), e_wink_5 = COALESCE($138, e_wink_5), - e_wink_6 = COALESCE($139, e_wink_6), e_wink_7 = COALESCE($140, e_wink_7), e_wink_8 = COALESCE($141, e_wink_8), + l_skin_0 = COALESCE($7, l_skin_0), + l_skin_1 = COALESCE($8, l_skin_1), + l_skin_2 = COALESCE($9, l_skin_2), + l_skin_3 = COALESCE($10, l_skin_3), + l_skin_4 = COALESCE($11, l_skin_4), + l_skin_5 = COALESCE($12, l_skin_5), + l_skin_6 = COALESCE($13, l_skin_6), + l_skin_7 = COALESCE($14, l_skin_7), + l_skin_8 = COALESCE($15, l_skin_8), + l_clothes_0 = COALESCE($16, l_clothes_0), + l_clothes_1 = COALESCE($17, l_clothes_1), + l_clothes_2 = COALESCE($18, l_clothes_2), + l_clothes_3 = COALESCE($19, l_clothes_3), + l_clothes_4 = COALESCE($20, l_clothes_4), + l_clothes_5 = COALESCE($21, l_clothes_5), + l_clothes_6 = COALESCE($22, l_clothes_6), + l_clothes_7 = COALESCE($23, l_clothes_7), + l_clothes_8 = COALESCE($24, l_clothes_8), + l_accessories_0 = COALESCE($25, l_accessories_0), + l_accessories_1 = COALESCE($26, l_accessories_1), + l_accessories_2 = COALESCE($27, l_accessories_2), + l_accessories_3 = COALESCE($28, l_accessories_3), + l_accessories_4 = COALESCE($29, l_accessories_4), + l_accessories_5 = COALESCE($30, l_accessories_5), + l_accessories_6 = COALESCE($31, l_accessories_6), + l_accessories_7 = COALESCE($32, l_accessories_7), + l_accessories_8 = COALESCE($33, l_accessories_8), + e_neutral_0 = COALESCE($34, e_neutral_0), + e_neutral_1 = COALESCE($35, e_neutral_1), + e_neutral_2 = COALESCE($36, e_neutral_2), + e_neutral_3 = COALESCE($37, e_neutral_3), + e_neutral_4 = COALESCE($38, e_neutral_4), + e_neutral_5 = COALESCE($39, e_neutral_5), + e_neutral_6 = COALESCE($40, e_neutral_6), + e_neutral_7 = COALESCE($41, e_neutral_7), + e_neutral_8 = COALESCE($42, e_neutral_8), + e_happy_0 = COALESCE($43, e_happy_0), + e_happy_1 = COALESCE($44, e_happy_1), + e_happy_2 = COALESCE($45, e_happy_2), + e_happy_3 = COALESCE($46, e_happy_3), + e_happy_4 = COALESCE($47, e_happy_4), + e_happy_5 = COALESCE($48, e_happy_5), + e_happy_6 = COALESCE($49, e_happy_6), + e_happy_7 = COALESCE($50, e_happy_7), + e_happy_8 = COALESCE($51, e_happy_8), + e_sad_0 = COALESCE($52, e_sad_0), + e_sad_1 = COALESCE($53, e_sad_1), + e_sad_2 = COALESCE($54, e_sad_2), + e_sad_3 = COALESCE($55, e_sad_3), + e_sad_4 = COALESCE($56, e_sad_4), + e_sad_5 = COALESCE($57, e_sad_5), + e_sad_6 = COALESCE($58, e_sad_6), + e_sad_7 = COALESCE($59, e_sad_7), + e_sad_8 = COALESCE($60, e_sad_8), + e_angry_0 = COALESCE($61, e_angry_0), + e_angry_1 = COALESCE($62, e_angry_1), + e_angry_2 = COALESCE($63, e_angry_2), + e_angry_3 = COALESCE($64, e_angry_3), + e_angry_4 = COALESCE($65, e_angry_4), + e_angry_5 = COALESCE($66, e_angry_5), + e_angry_6 = COALESCE($67, e_angry_6), + e_angry_7 = COALESCE($68, e_angry_7), + e_angry_8 = COALESCE($69, e_angry_8), + e_surprised_0 = COALESCE($70, e_surprised_0), + e_surprised_1 = COALESCE($71, e_surprised_1), + e_surprised_2 = COALESCE($72, e_surprised_2), + e_surprised_3 = COALESCE($73, e_surprised_3), + e_surprised_4 = COALESCE($74, e_surprised_4), + e_surprised_5 = COALESCE($75, e_surprised_5), + e_surprised_6 = COALESCE($76, e_surprised_6), + e_surprised_7 = COALESCE($77, e_surprised_7), + e_surprised_8 = COALESCE($78, e_surprised_8), + e_thinking_0 = COALESCE($79, e_thinking_0), + e_thinking_1 = COALESCE($80, e_thinking_1), + e_thinking_2 = COALESCE($81, e_thinking_2), + e_thinking_3 = COALESCE($82, e_thinking_3), + e_thinking_4 = COALESCE($83, e_thinking_4), + e_thinking_5 = COALESCE($84, e_thinking_5), + e_thinking_6 = COALESCE($85, e_thinking_6), + e_thinking_7 = COALESCE($86, e_thinking_7), + e_thinking_8 = COALESCE($87, e_thinking_8), + e_laughing_0 = COALESCE($88, e_laughing_0), + e_laughing_1 = COALESCE($89, e_laughing_1), + e_laughing_2 = COALESCE($90, e_laughing_2), + e_laughing_3 = COALESCE($91, e_laughing_3), + e_laughing_4 = COALESCE($92, e_laughing_4), + e_laughing_5 = COALESCE($93, e_laughing_5), + e_laughing_6 = COALESCE($94, e_laughing_6), + e_laughing_7 = COALESCE($95, e_laughing_7), + e_laughing_8 = COALESCE($96, e_laughing_8), + e_crying_0 = COALESCE($97, e_crying_0), + e_crying_1 = COALESCE($98, e_crying_1), + e_crying_2 = COALESCE($99, e_crying_2), + e_crying_3 = COALESCE($100, e_crying_3), + e_crying_4 = COALESCE($101, e_crying_4), + e_crying_5 = COALESCE($102, e_crying_5), + e_crying_6 = COALESCE($103, e_crying_6), + e_crying_7 = COALESCE($104, e_crying_7), + e_crying_8 = COALESCE($105, e_crying_8), + e_love_0 = COALESCE($106, e_love_0), + e_love_1 = COALESCE($107, e_love_1), + e_love_2 = COALESCE($108, e_love_2), + e_love_3 = COALESCE($109, e_love_3), + e_love_4 = COALESCE($110, e_love_4), + e_love_5 = COALESCE($111, e_love_5), + e_love_6 = COALESCE($112, e_love_6), + e_love_7 = COALESCE($113, e_love_7), + e_love_8 = COALESCE($114, e_love_8), + e_confused_0 = COALESCE($115, e_confused_0), + e_confused_1 = COALESCE($116, e_confused_1), + e_confused_2 = COALESCE($117, e_confused_2), + e_confused_3 = COALESCE($118, e_confused_3), + e_confused_4 = COALESCE($119, e_confused_4), + e_confused_5 = COALESCE($120, e_confused_5), + e_confused_6 = COALESCE($121, e_confused_6), + e_confused_7 = COALESCE($122, e_confused_7), + e_confused_8 = COALESCE($123, e_confused_8), + e_sleeping_0 = COALESCE($124, e_sleeping_0), + e_sleeping_1 = COALESCE($125, e_sleeping_1), + e_sleeping_2 = COALESCE($126, e_sleeping_2), + e_sleeping_3 = COALESCE($127, e_sleeping_3), + e_sleeping_4 = COALESCE($128, e_sleeping_4), + e_sleeping_5 = COALESCE($129, e_sleeping_5), + e_sleeping_6 = COALESCE($130, e_sleeping_6), + e_sleeping_7 = COALESCE($131, e_sleeping_7), + e_sleeping_8 = COALESCE($132, e_sleeping_8), + e_wink_0 = COALESCE($133, e_wink_0), + e_wink_1 = COALESCE($134, e_wink_1), + e_wink_2 = COALESCE($135, e_wink_2), + e_wink_3 = COALESCE($136, e_wink_3), + e_wink_4 = COALESCE($137, e_wink_4), + e_wink_5 = COALESCE($138, e_wink_5), + e_wink_6 = COALESCE($139, e_wink_6), + e_wink_7 = COALESCE($140, e_wink_7), + e_wink_8 = COALESCE($141, e_wink_8), updated_at = now() WHERE id = $1 RETURNING * @@ -537,65 +892,155 @@ pub async fn update_realm_avatar<'e>( .bind(req.is_active) .bind(&req.thumbnail_path) // Skin layer - .bind(req.l_skin_0).bind(req.l_skin_1).bind(req.l_skin_2) - .bind(req.l_skin_3).bind(req.l_skin_4).bind(req.l_skin_5) - .bind(req.l_skin_6).bind(req.l_skin_7).bind(req.l_skin_8) + .bind(req.l_skin_0) + .bind(req.l_skin_1) + .bind(req.l_skin_2) + .bind(req.l_skin_3) + .bind(req.l_skin_4) + .bind(req.l_skin_5) + .bind(req.l_skin_6) + .bind(req.l_skin_7) + .bind(req.l_skin_8) // Clothes layer - .bind(req.l_clothes_0).bind(req.l_clothes_1).bind(req.l_clothes_2) - .bind(req.l_clothes_3).bind(req.l_clothes_4).bind(req.l_clothes_5) - .bind(req.l_clothes_6).bind(req.l_clothes_7).bind(req.l_clothes_8) + .bind(req.l_clothes_0) + .bind(req.l_clothes_1) + .bind(req.l_clothes_2) + .bind(req.l_clothes_3) + .bind(req.l_clothes_4) + .bind(req.l_clothes_5) + .bind(req.l_clothes_6) + .bind(req.l_clothes_7) + .bind(req.l_clothes_8) // Accessories layer - .bind(req.l_accessories_0).bind(req.l_accessories_1).bind(req.l_accessories_2) - .bind(req.l_accessories_3).bind(req.l_accessories_4).bind(req.l_accessories_5) - .bind(req.l_accessories_6).bind(req.l_accessories_7).bind(req.l_accessories_8) + .bind(req.l_accessories_0) + .bind(req.l_accessories_1) + .bind(req.l_accessories_2) + .bind(req.l_accessories_3) + .bind(req.l_accessories_4) + .bind(req.l_accessories_5) + .bind(req.l_accessories_6) + .bind(req.l_accessories_7) + .bind(req.l_accessories_8) // Neutral emotion - .bind(req.e_neutral_0).bind(req.e_neutral_1).bind(req.e_neutral_2) - .bind(req.e_neutral_3).bind(req.e_neutral_4).bind(req.e_neutral_5) - .bind(req.e_neutral_6).bind(req.e_neutral_7).bind(req.e_neutral_8) + .bind(req.e_neutral_0) + .bind(req.e_neutral_1) + .bind(req.e_neutral_2) + .bind(req.e_neutral_3) + .bind(req.e_neutral_4) + .bind(req.e_neutral_5) + .bind(req.e_neutral_6) + .bind(req.e_neutral_7) + .bind(req.e_neutral_8) // Happy emotion - .bind(req.e_happy_0).bind(req.e_happy_1).bind(req.e_happy_2) - .bind(req.e_happy_3).bind(req.e_happy_4).bind(req.e_happy_5) - .bind(req.e_happy_6).bind(req.e_happy_7).bind(req.e_happy_8) + .bind(req.e_happy_0) + .bind(req.e_happy_1) + .bind(req.e_happy_2) + .bind(req.e_happy_3) + .bind(req.e_happy_4) + .bind(req.e_happy_5) + .bind(req.e_happy_6) + .bind(req.e_happy_7) + .bind(req.e_happy_8) // Sad emotion - .bind(req.e_sad_0).bind(req.e_sad_1).bind(req.e_sad_2) - .bind(req.e_sad_3).bind(req.e_sad_4).bind(req.e_sad_5) - .bind(req.e_sad_6).bind(req.e_sad_7).bind(req.e_sad_8) + .bind(req.e_sad_0) + .bind(req.e_sad_1) + .bind(req.e_sad_2) + .bind(req.e_sad_3) + .bind(req.e_sad_4) + .bind(req.e_sad_5) + .bind(req.e_sad_6) + .bind(req.e_sad_7) + .bind(req.e_sad_8) // Angry emotion - .bind(req.e_angry_0).bind(req.e_angry_1).bind(req.e_angry_2) - .bind(req.e_angry_3).bind(req.e_angry_4).bind(req.e_angry_5) - .bind(req.e_angry_6).bind(req.e_angry_7).bind(req.e_angry_8) + .bind(req.e_angry_0) + .bind(req.e_angry_1) + .bind(req.e_angry_2) + .bind(req.e_angry_3) + .bind(req.e_angry_4) + .bind(req.e_angry_5) + .bind(req.e_angry_6) + .bind(req.e_angry_7) + .bind(req.e_angry_8) // Surprised emotion - .bind(req.e_surprised_0).bind(req.e_surprised_1).bind(req.e_surprised_2) - .bind(req.e_surprised_3).bind(req.e_surprised_4).bind(req.e_surprised_5) - .bind(req.e_surprised_6).bind(req.e_surprised_7).bind(req.e_surprised_8) + .bind(req.e_surprised_0) + .bind(req.e_surprised_1) + .bind(req.e_surprised_2) + .bind(req.e_surprised_3) + .bind(req.e_surprised_4) + .bind(req.e_surprised_5) + .bind(req.e_surprised_6) + .bind(req.e_surprised_7) + .bind(req.e_surprised_8) // Thinking emotion - .bind(req.e_thinking_0).bind(req.e_thinking_1).bind(req.e_thinking_2) - .bind(req.e_thinking_3).bind(req.e_thinking_4).bind(req.e_thinking_5) - .bind(req.e_thinking_6).bind(req.e_thinking_7).bind(req.e_thinking_8) + .bind(req.e_thinking_0) + .bind(req.e_thinking_1) + .bind(req.e_thinking_2) + .bind(req.e_thinking_3) + .bind(req.e_thinking_4) + .bind(req.e_thinking_5) + .bind(req.e_thinking_6) + .bind(req.e_thinking_7) + .bind(req.e_thinking_8) // Laughing emotion - .bind(req.e_laughing_0).bind(req.e_laughing_1).bind(req.e_laughing_2) - .bind(req.e_laughing_3).bind(req.e_laughing_4).bind(req.e_laughing_5) - .bind(req.e_laughing_6).bind(req.e_laughing_7).bind(req.e_laughing_8) + .bind(req.e_laughing_0) + .bind(req.e_laughing_1) + .bind(req.e_laughing_2) + .bind(req.e_laughing_3) + .bind(req.e_laughing_4) + .bind(req.e_laughing_5) + .bind(req.e_laughing_6) + .bind(req.e_laughing_7) + .bind(req.e_laughing_8) // Crying emotion - .bind(req.e_crying_0).bind(req.e_crying_1).bind(req.e_crying_2) - .bind(req.e_crying_3).bind(req.e_crying_4).bind(req.e_crying_5) - .bind(req.e_crying_6).bind(req.e_crying_7).bind(req.e_crying_8) + .bind(req.e_crying_0) + .bind(req.e_crying_1) + .bind(req.e_crying_2) + .bind(req.e_crying_3) + .bind(req.e_crying_4) + .bind(req.e_crying_5) + .bind(req.e_crying_6) + .bind(req.e_crying_7) + .bind(req.e_crying_8) // Love emotion - .bind(req.e_love_0).bind(req.e_love_1).bind(req.e_love_2) - .bind(req.e_love_3).bind(req.e_love_4).bind(req.e_love_5) - .bind(req.e_love_6).bind(req.e_love_7).bind(req.e_love_8) + .bind(req.e_love_0) + .bind(req.e_love_1) + .bind(req.e_love_2) + .bind(req.e_love_3) + .bind(req.e_love_4) + .bind(req.e_love_5) + .bind(req.e_love_6) + .bind(req.e_love_7) + .bind(req.e_love_8) // Confused emotion - .bind(req.e_confused_0).bind(req.e_confused_1).bind(req.e_confused_2) - .bind(req.e_confused_3).bind(req.e_confused_4).bind(req.e_confused_5) - .bind(req.e_confused_6).bind(req.e_confused_7).bind(req.e_confused_8) + .bind(req.e_confused_0) + .bind(req.e_confused_1) + .bind(req.e_confused_2) + .bind(req.e_confused_3) + .bind(req.e_confused_4) + .bind(req.e_confused_5) + .bind(req.e_confused_6) + .bind(req.e_confused_7) + .bind(req.e_confused_8) // Sleeping emotion - .bind(req.e_sleeping_0).bind(req.e_sleeping_1).bind(req.e_sleeping_2) - .bind(req.e_sleeping_3).bind(req.e_sleeping_4).bind(req.e_sleeping_5) - .bind(req.e_sleeping_6).bind(req.e_sleeping_7).bind(req.e_sleeping_8) + .bind(req.e_sleeping_0) + .bind(req.e_sleeping_1) + .bind(req.e_sleeping_2) + .bind(req.e_sleeping_3) + .bind(req.e_sleeping_4) + .bind(req.e_sleeping_5) + .bind(req.e_sleeping_6) + .bind(req.e_sleeping_7) + .bind(req.e_sleeping_8) // Wink emotion - .bind(req.e_wink_0).bind(req.e_wink_1).bind(req.e_wink_2) - .bind(req.e_wink_3).bind(req.e_wink_4).bind(req.e_wink_5) - .bind(req.e_wink_6).bind(req.e_wink_7).bind(req.e_wink_8) + .bind(req.e_wink_0) + .bind(req.e_wink_1) + .bind(req.e_wink_2) + .bind(req.e_wink_3) + .bind(req.e_wink_4) + .bind(req.e_wink_5) + .bind(req.e_wink_6) + .bind(req.e_wink_7) + .bind(req.e_wink_8) .fetch_one(executor) .await?; diff --git a/crates/chattyness-db/src/queries/scenes.rs b/crates/chattyness-db/src/queries/scenes.rs index 7b8f4ed..5d6405e 100644 --- a/crates/chattyness-db/src/queries/scenes.rs +++ b/crates/chattyness-db/src/queries/scenes.rs @@ -4,7 +4,7 @@ use sqlx::PgExecutor; use uuid::Uuid; use crate::models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest}; -use chattyness_error::{AppError, OptionExt}; +use chattyness_error::AppError; /// List all scenes for a realm. pub async fn list_scenes_for_realm<'e>( @@ -374,7 +374,7 @@ pub async fn update_scene<'e>( let scene = query_builder .fetch_optional(executor) .await? - .or_not_found("Scene")?; + .ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; Ok(scene) } diff --git a/crates/chattyness-db/src/queries/server_avatars.rs b/crates/chattyness-db/src/queries/server_avatars.rs index 4544162..bc35d5d 100644 --- a/crates/chattyness-db/src/queries/server_avatars.rs +++ b/crates/chattyness-db/src/queries/server_avatars.rs @@ -3,22 +3,15 @@ //! Server avatars are pre-configured avatar configurations available globally //! across all realms. They reference server.props directly (not inventory items). +use std::collections::HashMap; + use chrono::{DateTime, Duration, Utc}; use sqlx::PgExecutor; use uuid::Uuid; -use crate::extract_avatar_slots; -use crate::models::{AvatarRenderData, EmotionState, ServerAvatar, ServerAvatarWithPaths}; -use crate::queries::avatar_common::{ - avatar_paths_join_clause, avatar_paths_select_clause, build_prop_map, - resolve_slots_to_render_data, -}; +use crate::models::{AvatarRenderData, EmotionState, ServerAvatar}; use chattyness_error::AppError; -// ============================================================================= -// Basic Queries -// ============================================================================= - /// Get a server avatar by slug. pub async fn get_server_avatar_by_slug<'e>( executor: impl PgExecutor<'e>, @@ -75,11 +68,9 @@ pub async fn list_public_server_avatars<'e>( Ok(avatars) } -// ============================================================================= -// Avatar with Paths Queries -// ============================================================================= +use crate::models::ServerAvatarWithPaths; -/// Row type for server avatar with paths query (includes slug). +/// Row type for server avatar with paths query. #[derive(Debug, sqlx::FromRow)] struct ServerAvatarWithPathsRow { id: Uuid, @@ -116,7 +107,7 @@ struct ServerAvatarWithPathsRow { accessories_6: Option, accessories_7: Option, accessories_8: Option, - // Happy emotion layer paths + // Happy emotion layer paths (e1 - more inviting for store display) emotion_0: Option, emotion_1: Option, emotion_2: Option, @@ -160,51 +151,234 @@ impl From for ServerAvatarWithPaths { } /// List all active public server avatars with resolved asset paths. +/// +/// Joins with the props table to resolve prop UUIDs to asset paths, +/// suitable for client-side rendering without additional lookups. pub async fn list_public_server_avatars_with_paths<'e>( executor: impl PgExecutor<'e>, ) -> Result, AppError> { - let join_clause = avatar_paths_join_clause("server.props"); - let query = format!( + let rows = sqlx::query_as::<_, ServerAvatarWithPathsRow>( r#" SELECT a.id, a.slug, - {} + a.name, + a.description, + -- Skin layer + p_skin_0.asset_path AS skin_0, + p_skin_1.asset_path AS skin_1, + p_skin_2.asset_path AS skin_2, + p_skin_3.asset_path AS skin_3, + p_skin_4.asset_path AS skin_4, + p_skin_5.asset_path AS skin_5, + p_skin_6.asset_path AS skin_6, + p_skin_7.asset_path AS skin_7, + p_skin_8.asset_path AS skin_8, + -- Clothes layer + p_clothes_0.asset_path AS clothes_0, + p_clothes_1.asset_path AS clothes_1, + p_clothes_2.asset_path AS clothes_2, + p_clothes_3.asset_path AS clothes_3, + p_clothes_4.asset_path AS clothes_4, + p_clothes_5.asset_path AS clothes_5, + p_clothes_6.asset_path AS clothes_6, + p_clothes_7.asset_path AS clothes_7, + p_clothes_8.asset_path AS clothes_8, + -- Accessories layer + p_acc_0.asset_path AS accessories_0, + p_acc_1.asset_path AS accessories_1, + p_acc_2.asset_path AS accessories_2, + p_acc_3.asset_path AS accessories_3, + p_acc_4.asset_path AS accessories_4, + p_acc_5.asset_path AS accessories_5, + p_acc_6.asset_path AS accessories_6, + p_acc_7.asset_path AS accessories_7, + p_acc_8.asset_path AS accessories_8, + -- Happy emotion layer (e1 - more inviting for store display) + p_emo_0.asset_path AS emotion_0, + p_emo_1.asset_path AS emotion_1, + p_emo_2.asset_path AS emotion_2, + p_emo_3.asset_path AS emotion_3, + p_emo_4.asset_path AS emotion_4, + p_emo_5.asset_path AS emotion_5, + p_emo_6.asset_path AS emotion_6, + p_emo_7.asset_path AS emotion_7, + p_emo_8.asset_path AS emotion_8 FROM server.avatars a - {} + -- Skin layer joins + LEFT JOIN server.props p_skin_0 ON a.l_skin_0 = p_skin_0.id + LEFT JOIN server.props p_skin_1 ON a.l_skin_1 = p_skin_1.id + LEFT JOIN server.props p_skin_2 ON a.l_skin_2 = p_skin_2.id + LEFT JOIN server.props p_skin_3 ON a.l_skin_3 = p_skin_3.id + LEFT JOIN server.props p_skin_4 ON a.l_skin_4 = p_skin_4.id + LEFT JOIN server.props p_skin_5 ON a.l_skin_5 = p_skin_5.id + LEFT JOIN server.props p_skin_6 ON a.l_skin_6 = p_skin_6.id + LEFT JOIN server.props p_skin_7 ON a.l_skin_7 = p_skin_7.id + LEFT JOIN server.props p_skin_8 ON a.l_skin_8 = p_skin_8.id + -- Clothes layer joins + LEFT JOIN server.props p_clothes_0 ON a.l_clothes_0 = p_clothes_0.id + LEFT JOIN server.props p_clothes_1 ON a.l_clothes_1 = p_clothes_1.id + LEFT JOIN server.props p_clothes_2 ON a.l_clothes_2 = p_clothes_2.id + LEFT JOIN server.props p_clothes_3 ON a.l_clothes_3 = p_clothes_3.id + LEFT JOIN server.props p_clothes_4 ON a.l_clothes_4 = p_clothes_4.id + LEFT JOIN server.props p_clothes_5 ON a.l_clothes_5 = p_clothes_5.id + LEFT JOIN server.props p_clothes_6 ON a.l_clothes_6 = p_clothes_6.id + LEFT JOIN server.props p_clothes_7 ON a.l_clothes_7 = p_clothes_7.id + LEFT JOIN server.props p_clothes_8 ON a.l_clothes_8 = p_clothes_8.id + -- Accessories layer joins + LEFT JOIN server.props p_acc_0 ON a.l_accessories_0 = p_acc_0.id + LEFT JOIN server.props p_acc_1 ON a.l_accessories_1 = p_acc_1.id + LEFT JOIN server.props p_acc_2 ON a.l_accessories_2 = p_acc_2.id + LEFT JOIN server.props p_acc_3 ON a.l_accessories_3 = p_acc_3.id + LEFT JOIN server.props p_acc_4 ON a.l_accessories_4 = p_acc_4.id + LEFT JOIN server.props p_acc_5 ON a.l_accessories_5 = p_acc_5.id + LEFT JOIN server.props p_acc_6 ON a.l_accessories_6 = p_acc_6.id + LEFT JOIN server.props p_acc_7 ON a.l_accessories_7 = p_acc_7.id + LEFT JOIN server.props p_acc_8 ON a.l_accessories_8 = p_acc_8.id + -- Happy emotion layer joins (e1 - more inviting for store display) + LEFT JOIN server.props p_emo_0 ON a.e_happy_0 = p_emo_0.id + LEFT JOIN server.props p_emo_1 ON a.e_happy_1 = p_emo_1.id + LEFT JOIN server.props p_emo_2 ON a.e_happy_2 = p_emo_2.id + LEFT JOIN server.props p_emo_3 ON a.e_happy_3 = p_emo_3.id + LEFT JOIN server.props p_emo_4 ON a.e_happy_4 = p_emo_4.id + LEFT JOIN server.props p_emo_5 ON a.e_happy_5 = p_emo_5.id + LEFT JOIN server.props p_emo_6 ON a.e_happy_6 = p_emo_6.id + LEFT JOIN server.props p_emo_7 ON a.e_happy_7 = p_emo_7.id + LEFT JOIN server.props p_emo_8 ON a.e_happy_8 = p_emo_8.id WHERE a.is_active = true AND a.is_public = true ORDER BY a.name ASC "#, - avatar_paths_select_clause(), - join_clause - ); - - let rows = sqlx::query_as::<_, ServerAvatarWithPathsRow>(&query) - .fetch_all(executor) - .await?; + ) + .fetch_all(executor) + .await?; Ok(rows.into_iter().map(ServerAvatarWithPaths::from).collect()) } -// ============================================================================= -// Render Data Resolution -// ============================================================================= +/// Row type for prop asset lookup. +#[derive(Debug, sqlx::FromRow)] +struct PropAssetRow { + id: Uuid, + asset_path: String, +} /// Resolve a server avatar to render data. +/// Joins the avatar's prop UUIDs with server.props to get asset paths. pub async fn resolve_server_avatar_to_render_data<'e>( executor: impl PgExecutor<'e>, avatar: &ServerAvatar, current_emotion: EmotionState, ) -> Result { - let slots = extract_avatar_slots!(avatar); - let prop_ids = slots.collect_render_prop_ids(current_emotion); - let prop_map = build_prop_map(executor, &prop_ids, "server.props").await?; - Ok(resolve_slots_to_render_data(avatar.id, &slots, current_emotion, &prop_map)) -} + // Collect all non-null prop UUIDs + let mut prop_ids: Vec = Vec::new(); -// ============================================================================= -// Forced Avatar Management -// ============================================================================= + // Content layers + for id in [ + avatar.l_skin_0, avatar.l_skin_1, avatar.l_skin_2, + avatar.l_skin_3, avatar.l_skin_4, avatar.l_skin_5, + avatar.l_skin_6, avatar.l_skin_7, avatar.l_skin_8, + avatar.l_clothes_0, avatar.l_clothes_1, avatar.l_clothes_2, + avatar.l_clothes_3, avatar.l_clothes_4, avatar.l_clothes_5, + avatar.l_clothes_6, avatar.l_clothes_7, avatar.l_clothes_8, + avatar.l_accessories_0, avatar.l_accessories_1, avatar.l_accessories_2, + avatar.l_accessories_3, avatar.l_accessories_4, avatar.l_accessories_5, + avatar.l_accessories_6, avatar.l_accessories_7, avatar.l_accessories_8, + ].iter().flatten() { + prop_ids.push(*id); + } + + // Get emotion layer slots based on current emotion + let emotion_slots: [Option; 9] = match current_emotion { + EmotionState::Neutral => [avatar.e_neutral_0, avatar.e_neutral_1, avatar.e_neutral_2, + avatar.e_neutral_3, avatar.e_neutral_4, avatar.e_neutral_5, + avatar.e_neutral_6, avatar.e_neutral_7, avatar.e_neutral_8], + EmotionState::Happy => [avatar.e_happy_0, avatar.e_happy_1, avatar.e_happy_2, + avatar.e_happy_3, avatar.e_happy_4, avatar.e_happy_5, + avatar.e_happy_6, avatar.e_happy_7, avatar.e_happy_8], + EmotionState::Sad => [avatar.e_sad_0, avatar.e_sad_1, avatar.e_sad_2, + avatar.e_sad_3, avatar.e_sad_4, avatar.e_sad_5, + avatar.e_sad_6, avatar.e_sad_7, avatar.e_sad_8], + EmotionState::Angry => [avatar.e_angry_0, avatar.e_angry_1, avatar.e_angry_2, + avatar.e_angry_3, avatar.e_angry_4, avatar.e_angry_5, + avatar.e_angry_6, avatar.e_angry_7, avatar.e_angry_8], + EmotionState::Surprised => [avatar.e_surprised_0, avatar.e_surprised_1, avatar.e_surprised_2, + avatar.e_surprised_3, avatar.e_surprised_4, avatar.e_surprised_5, + avatar.e_surprised_6, avatar.e_surprised_7, avatar.e_surprised_8], + EmotionState::Thinking => [avatar.e_thinking_0, avatar.e_thinking_1, avatar.e_thinking_2, + avatar.e_thinking_3, avatar.e_thinking_4, avatar.e_thinking_5, + avatar.e_thinking_6, avatar.e_thinking_7, avatar.e_thinking_8], + EmotionState::Laughing => [avatar.e_laughing_0, avatar.e_laughing_1, avatar.e_laughing_2, + avatar.e_laughing_3, avatar.e_laughing_4, avatar.e_laughing_5, + avatar.e_laughing_6, avatar.e_laughing_7, avatar.e_laughing_8], + EmotionState::Crying => [avatar.e_crying_0, avatar.e_crying_1, avatar.e_crying_2, + avatar.e_crying_3, avatar.e_crying_4, avatar.e_crying_5, + avatar.e_crying_6, avatar.e_crying_7, avatar.e_crying_8], + EmotionState::Love => [avatar.e_love_0, avatar.e_love_1, avatar.e_love_2, + avatar.e_love_3, avatar.e_love_4, avatar.e_love_5, + avatar.e_love_6, avatar.e_love_7, avatar.e_love_8], + EmotionState::Confused => [avatar.e_confused_0, avatar.e_confused_1, avatar.e_confused_2, + avatar.e_confused_3, avatar.e_confused_4, avatar.e_confused_5, + avatar.e_confused_6, avatar.e_confused_7, avatar.e_confused_8], + EmotionState::Sleeping => [avatar.e_sleeping_0, avatar.e_sleeping_1, avatar.e_sleeping_2, + avatar.e_sleeping_3, avatar.e_sleeping_4, avatar.e_sleeping_5, + avatar.e_sleeping_6, avatar.e_sleeping_7, avatar.e_sleeping_8], + EmotionState::Wink => [avatar.e_wink_0, avatar.e_wink_1, avatar.e_wink_2, + avatar.e_wink_3, avatar.e_wink_4, avatar.e_wink_5, + avatar.e_wink_6, avatar.e_wink_7, avatar.e_wink_8], + }; + + for id in emotion_slots.iter().flatten() { + prop_ids.push(*id); + } + + // Bulk lookup all prop asset paths + let prop_map: HashMap = if prop_ids.is_empty() { + HashMap::new() + } else { + let rows = sqlx::query_as::<_, PropAssetRow>( + r#" + SELECT id, asset_path + FROM server.props + WHERE id = ANY($1) + "#, + ) + .bind(&prop_ids) + .fetch_all(executor) + .await?; + + rows.into_iter().map(|r| (r.id, r.asset_path)).collect() + }; + + // Helper to look up path + let get_path = |id: Option| -> Option { + id.and_then(|id| prop_map.get(&id).cloned()) + }; + + Ok(AvatarRenderData { + avatar_id: avatar.id, + current_emotion, + skin_layer: [ + get_path(avatar.l_skin_0), get_path(avatar.l_skin_1), get_path(avatar.l_skin_2), + get_path(avatar.l_skin_3), get_path(avatar.l_skin_4), get_path(avatar.l_skin_5), + get_path(avatar.l_skin_6), get_path(avatar.l_skin_7), get_path(avatar.l_skin_8), + ], + clothes_layer: [ + get_path(avatar.l_clothes_0), get_path(avatar.l_clothes_1), get_path(avatar.l_clothes_2), + get_path(avatar.l_clothes_3), get_path(avatar.l_clothes_4), get_path(avatar.l_clothes_5), + get_path(avatar.l_clothes_6), get_path(avatar.l_clothes_7), get_path(avatar.l_clothes_8), + ], + accessories_layer: [ + get_path(avatar.l_accessories_0), get_path(avatar.l_accessories_1), get_path(avatar.l_accessories_2), + get_path(avatar.l_accessories_3), get_path(avatar.l_accessories_4), get_path(avatar.l_accessories_5), + get_path(avatar.l_accessories_6), get_path(avatar.l_accessories_7), get_path(avatar.l_accessories_8), + ], + emotion_layer: [ + get_path(emotion_slots[0]), get_path(emotion_slots[1]), get_path(emotion_slots[2]), + get_path(emotion_slots[3]), get_path(emotion_slots[4]), get_path(emotion_slots[5]), + get_path(emotion_slots[6]), get_path(emotion_slots[7]), get_path(emotion_slots[8]), + ], + }) +} /// Apply a forced server avatar to a user. pub async fn apply_forced_server_avatar<'e>( @@ -425,65 +599,155 @@ pub async fn create_server_avatar<'e>( .bind(&req.thumbnail_path) .bind(created_by) // Skin layer - .bind(req.l_skin_0).bind(req.l_skin_1).bind(req.l_skin_2) - .bind(req.l_skin_3).bind(req.l_skin_4).bind(req.l_skin_5) - .bind(req.l_skin_6).bind(req.l_skin_7).bind(req.l_skin_8) + .bind(req.l_skin_0) + .bind(req.l_skin_1) + .bind(req.l_skin_2) + .bind(req.l_skin_3) + .bind(req.l_skin_4) + .bind(req.l_skin_5) + .bind(req.l_skin_6) + .bind(req.l_skin_7) + .bind(req.l_skin_8) // Clothes layer - .bind(req.l_clothes_0).bind(req.l_clothes_1).bind(req.l_clothes_2) - .bind(req.l_clothes_3).bind(req.l_clothes_4).bind(req.l_clothes_5) - .bind(req.l_clothes_6).bind(req.l_clothes_7).bind(req.l_clothes_8) + .bind(req.l_clothes_0) + .bind(req.l_clothes_1) + .bind(req.l_clothes_2) + .bind(req.l_clothes_3) + .bind(req.l_clothes_4) + .bind(req.l_clothes_5) + .bind(req.l_clothes_6) + .bind(req.l_clothes_7) + .bind(req.l_clothes_8) // Accessories layer - .bind(req.l_accessories_0).bind(req.l_accessories_1).bind(req.l_accessories_2) - .bind(req.l_accessories_3).bind(req.l_accessories_4).bind(req.l_accessories_5) - .bind(req.l_accessories_6).bind(req.l_accessories_7).bind(req.l_accessories_8) + .bind(req.l_accessories_0) + .bind(req.l_accessories_1) + .bind(req.l_accessories_2) + .bind(req.l_accessories_3) + .bind(req.l_accessories_4) + .bind(req.l_accessories_5) + .bind(req.l_accessories_6) + .bind(req.l_accessories_7) + .bind(req.l_accessories_8) // Neutral emotion - .bind(req.e_neutral_0).bind(req.e_neutral_1).bind(req.e_neutral_2) - .bind(req.e_neutral_3).bind(req.e_neutral_4).bind(req.e_neutral_5) - .bind(req.e_neutral_6).bind(req.e_neutral_7).bind(req.e_neutral_8) + .bind(req.e_neutral_0) + .bind(req.e_neutral_1) + .bind(req.e_neutral_2) + .bind(req.e_neutral_3) + .bind(req.e_neutral_4) + .bind(req.e_neutral_5) + .bind(req.e_neutral_6) + .bind(req.e_neutral_7) + .bind(req.e_neutral_8) // Happy emotion - .bind(req.e_happy_0).bind(req.e_happy_1).bind(req.e_happy_2) - .bind(req.e_happy_3).bind(req.e_happy_4).bind(req.e_happy_5) - .bind(req.e_happy_6).bind(req.e_happy_7).bind(req.e_happy_8) + .bind(req.e_happy_0) + .bind(req.e_happy_1) + .bind(req.e_happy_2) + .bind(req.e_happy_3) + .bind(req.e_happy_4) + .bind(req.e_happy_5) + .bind(req.e_happy_6) + .bind(req.e_happy_7) + .bind(req.e_happy_8) // Sad emotion - .bind(req.e_sad_0).bind(req.e_sad_1).bind(req.e_sad_2) - .bind(req.e_sad_3).bind(req.e_sad_4).bind(req.e_sad_5) - .bind(req.e_sad_6).bind(req.e_sad_7).bind(req.e_sad_8) + .bind(req.e_sad_0) + .bind(req.e_sad_1) + .bind(req.e_sad_2) + .bind(req.e_sad_3) + .bind(req.e_sad_4) + .bind(req.e_sad_5) + .bind(req.e_sad_6) + .bind(req.e_sad_7) + .bind(req.e_sad_8) // Angry emotion - .bind(req.e_angry_0).bind(req.e_angry_1).bind(req.e_angry_2) - .bind(req.e_angry_3).bind(req.e_angry_4).bind(req.e_angry_5) - .bind(req.e_angry_6).bind(req.e_angry_7).bind(req.e_angry_8) + .bind(req.e_angry_0) + .bind(req.e_angry_1) + .bind(req.e_angry_2) + .bind(req.e_angry_3) + .bind(req.e_angry_4) + .bind(req.e_angry_5) + .bind(req.e_angry_6) + .bind(req.e_angry_7) + .bind(req.e_angry_8) // Surprised emotion - .bind(req.e_surprised_0).bind(req.e_surprised_1).bind(req.e_surprised_2) - .bind(req.e_surprised_3).bind(req.e_surprised_4).bind(req.e_surprised_5) - .bind(req.e_surprised_6).bind(req.e_surprised_7).bind(req.e_surprised_8) + .bind(req.e_surprised_0) + .bind(req.e_surprised_1) + .bind(req.e_surprised_2) + .bind(req.e_surprised_3) + .bind(req.e_surprised_4) + .bind(req.e_surprised_5) + .bind(req.e_surprised_6) + .bind(req.e_surprised_7) + .bind(req.e_surprised_8) // Thinking emotion - .bind(req.e_thinking_0).bind(req.e_thinking_1).bind(req.e_thinking_2) - .bind(req.e_thinking_3).bind(req.e_thinking_4).bind(req.e_thinking_5) - .bind(req.e_thinking_6).bind(req.e_thinking_7).bind(req.e_thinking_8) + .bind(req.e_thinking_0) + .bind(req.e_thinking_1) + .bind(req.e_thinking_2) + .bind(req.e_thinking_3) + .bind(req.e_thinking_4) + .bind(req.e_thinking_5) + .bind(req.e_thinking_6) + .bind(req.e_thinking_7) + .bind(req.e_thinking_8) // Laughing emotion - .bind(req.e_laughing_0).bind(req.e_laughing_1).bind(req.e_laughing_2) - .bind(req.e_laughing_3).bind(req.e_laughing_4).bind(req.e_laughing_5) - .bind(req.e_laughing_6).bind(req.e_laughing_7).bind(req.e_laughing_8) + .bind(req.e_laughing_0) + .bind(req.e_laughing_1) + .bind(req.e_laughing_2) + .bind(req.e_laughing_3) + .bind(req.e_laughing_4) + .bind(req.e_laughing_5) + .bind(req.e_laughing_6) + .bind(req.e_laughing_7) + .bind(req.e_laughing_8) // Crying emotion - .bind(req.e_crying_0).bind(req.e_crying_1).bind(req.e_crying_2) - .bind(req.e_crying_3).bind(req.e_crying_4).bind(req.e_crying_5) - .bind(req.e_crying_6).bind(req.e_crying_7).bind(req.e_crying_8) + .bind(req.e_crying_0) + .bind(req.e_crying_1) + .bind(req.e_crying_2) + .bind(req.e_crying_3) + .bind(req.e_crying_4) + .bind(req.e_crying_5) + .bind(req.e_crying_6) + .bind(req.e_crying_7) + .bind(req.e_crying_8) // Love emotion - .bind(req.e_love_0).bind(req.e_love_1).bind(req.e_love_2) - .bind(req.e_love_3).bind(req.e_love_4).bind(req.e_love_5) - .bind(req.e_love_6).bind(req.e_love_7).bind(req.e_love_8) + .bind(req.e_love_0) + .bind(req.e_love_1) + .bind(req.e_love_2) + .bind(req.e_love_3) + .bind(req.e_love_4) + .bind(req.e_love_5) + .bind(req.e_love_6) + .bind(req.e_love_7) + .bind(req.e_love_8) // Confused emotion - .bind(req.e_confused_0).bind(req.e_confused_1).bind(req.e_confused_2) - .bind(req.e_confused_3).bind(req.e_confused_4).bind(req.e_confused_5) - .bind(req.e_confused_6).bind(req.e_confused_7).bind(req.e_confused_8) + .bind(req.e_confused_0) + .bind(req.e_confused_1) + .bind(req.e_confused_2) + .bind(req.e_confused_3) + .bind(req.e_confused_4) + .bind(req.e_confused_5) + .bind(req.e_confused_6) + .bind(req.e_confused_7) + .bind(req.e_confused_8) // Sleeping emotion - .bind(req.e_sleeping_0).bind(req.e_sleeping_1).bind(req.e_sleeping_2) - .bind(req.e_sleeping_3).bind(req.e_sleeping_4).bind(req.e_sleeping_5) - .bind(req.e_sleeping_6).bind(req.e_sleeping_7).bind(req.e_sleeping_8) + .bind(req.e_sleeping_0) + .bind(req.e_sleeping_1) + .bind(req.e_sleeping_2) + .bind(req.e_sleeping_3) + .bind(req.e_sleeping_4) + .bind(req.e_sleeping_5) + .bind(req.e_sleeping_6) + .bind(req.e_sleeping_7) + .bind(req.e_sleeping_8) // Wink emotion - .bind(req.e_wink_0).bind(req.e_wink_1).bind(req.e_wink_2) - .bind(req.e_wink_3).bind(req.e_wink_4).bind(req.e_wink_5) - .bind(req.e_wink_6).bind(req.e_wink_7).bind(req.e_wink_8) + .bind(req.e_wink_0) + .bind(req.e_wink_1) + .bind(req.e_wink_2) + .bind(req.e_wink_3) + .bind(req.e_wink_4) + .bind(req.e_wink_5) + .bind(req.e_wink_6) + .bind(req.e_wink_7) + .bind(req.e_wink_8) .fetch_one(executor) .await?; @@ -504,51 +768,141 @@ pub async fn update_server_avatar<'e>( is_public = COALESCE($4, is_public), is_active = COALESCE($5, is_active), thumbnail_path = COALESCE($6, thumbnail_path), - l_skin_0 = COALESCE($7, l_skin_0), l_skin_1 = COALESCE($8, l_skin_1), l_skin_2 = COALESCE($9, l_skin_2), - l_skin_3 = COALESCE($10, l_skin_3), l_skin_4 = COALESCE($11, l_skin_4), l_skin_5 = COALESCE($12, l_skin_5), - l_skin_6 = COALESCE($13, l_skin_6), l_skin_7 = COALESCE($14, l_skin_7), l_skin_8 = COALESCE($15, l_skin_8), - l_clothes_0 = COALESCE($16, l_clothes_0), l_clothes_1 = COALESCE($17, l_clothes_1), l_clothes_2 = COALESCE($18, l_clothes_2), - l_clothes_3 = COALESCE($19, l_clothes_3), l_clothes_4 = COALESCE($20, l_clothes_4), l_clothes_5 = COALESCE($21, l_clothes_5), - l_clothes_6 = COALESCE($22, l_clothes_6), l_clothes_7 = COALESCE($23, l_clothes_7), l_clothes_8 = COALESCE($24, l_clothes_8), - l_accessories_0 = COALESCE($25, l_accessories_0), l_accessories_1 = COALESCE($26, l_accessories_1), l_accessories_2 = COALESCE($27, l_accessories_2), - l_accessories_3 = COALESCE($28, l_accessories_3), l_accessories_4 = COALESCE($29, l_accessories_4), l_accessories_5 = COALESCE($30, l_accessories_5), - l_accessories_6 = COALESCE($31, l_accessories_6), l_accessories_7 = COALESCE($32, l_accessories_7), l_accessories_8 = COALESCE($33, l_accessories_8), - e_neutral_0 = COALESCE($34, e_neutral_0), e_neutral_1 = COALESCE($35, e_neutral_1), e_neutral_2 = COALESCE($36, e_neutral_2), - e_neutral_3 = COALESCE($37, e_neutral_3), e_neutral_4 = COALESCE($38, e_neutral_4), e_neutral_5 = COALESCE($39, e_neutral_5), - e_neutral_6 = COALESCE($40, e_neutral_6), e_neutral_7 = COALESCE($41, e_neutral_7), e_neutral_8 = COALESCE($42, e_neutral_8), - e_happy_0 = COALESCE($43, e_happy_0), e_happy_1 = COALESCE($44, e_happy_1), e_happy_2 = COALESCE($45, e_happy_2), - e_happy_3 = COALESCE($46, e_happy_3), e_happy_4 = COALESCE($47, e_happy_4), e_happy_5 = COALESCE($48, e_happy_5), - e_happy_6 = COALESCE($49, e_happy_6), e_happy_7 = COALESCE($50, e_happy_7), e_happy_8 = COALESCE($51, e_happy_8), - e_sad_0 = COALESCE($52, e_sad_0), e_sad_1 = COALESCE($53, e_sad_1), e_sad_2 = COALESCE($54, e_sad_2), - e_sad_3 = COALESCE($55, e_sad_3), e_sad_4 = COALESCE($56, e_sad_4), e_sad_5 = COALESCE($57, e_sad_5), - e_sad_6 = COALESCE($58, e_sad_6), e_sad_7 = COALESCE($59, e_sad_7), e_sad_8 = COALESCE($60, e_sad_8), - e_angry_0 = COALESCE($61, e_angry_0), e_angry_1 = COALESCE($62, e_angry_1), e_angry_2 = COALESCE($63, e_angry_2), - e_angry_3 = COALESCE($64, e_angry_3), e_angry_4 = COALESCE($65, e_angry_4), e_angry_5 = COALESCE($66, e_angry_5), - e_angry_6 = COALESCE($67, e_angry_6), e_angry_7 = COALESCE($68, e_angry_7), e_angry_8 = COALESCE($69, e_angry_8), - e_surprised_0 = COALESCE($70, e_surprised_0), e_surprised_1 = COALESCE($71, e_surprised_1), e_surprised_2 = COALESCE($72, e_surprised_2), - e_surprised_3 = COALESCE($73, e_surprised_3), e_surprised_4 = COALESCE($74, e_surprised_4), e_surprised_5 = COALESCE($75, e_surprised_5), - e_surprised_6 = COALESCE($76, e_surprised_6), e_surprised_7 = COALESCE($77, e_surprised_7), e_surprised_8 = COALESCE($78, e_surprised_8), - e_thinking_0 = COALESCE($79, e_thinking_0), e_thinking_1 = COALESCE($80, e_thinking_1), e_thinking_2 = COALESCE($81, e_thinking_2), - e_thinking_3 = COALESCE($82, e_thinking_3), e_thinking_4 = COALESCE($83, e_thinking_4), e_thinking_5 = COALESCE($84, e_thinking_5), - e_thinking_6 = COALESCE($85, e_thinking_6), e_thinking_7 = COALESCE($86, e_thinking_7), e_thinking_8 = COALESCE($87, e_thinking_8), - e_laughing_0 = COALESCE($88, e_laughing_0), e_laughing_1 = COALESCE($89, e_laughing_1), e_laughing_2 = COALESCE($90, e_laughing_2), - e_laughing_3 = COALESCE($91, e_laughing_3), e_laughing_4 = COALESCE($92, e_laughing_4), e_laughing_5 = COALESCE($93, e_laughing_5), - e_laughing_6 = COALESCE($94, e_laughing_6), e_laughing_7 = COALESCE($95, e_laughing_7), e_laughing_8 = COALESCE($96, e_laughing_8), - e_crying_0 = COALESCE($97, e_crying_0), e_crying_1 = COALESCE($98, e_crying_1), e_crying_2 = COALESCE($99, e_crying_2), - e_crying_3 = COALESCE($100, e_crying_3), e_crying_4 = COALESCE($101, e_crying_4), e_crying_5 = COALESCE($102, e_crying_5), - e_crying_6 = COALESCE($103, e_crying_6), e_crying_7 = COALESCE($104, e_crying_7), e_crying_8 = COALESCE($105, e_crying_8), - e_love_0 = COALESCE($106, e_love_0), e_love_1 = COALESCE($107, e_love_1), e_love_2 = COALESCE($108, e_love_2), - e_love_3 = COALESCE($109, e_love_3), e_love_4 = COALESCE($110, e_love_4), e_love_5 = COALESCE($111, e_love_5), - e_love_6 = COALESCE($112, e_love_6), e_love_7 = COALESCE($113, e_love_7), e_love_8 = COALESCE($114, e_love_8), - e_confused_0 = COALESCE($115, e_confused_0), e_confused_1 = COALESCE($116, e_confused_1), e_confused_2 = COALESCE($117, e_confused_2), - e_confused_3 = COALESCE($118, e_confused_3), e_confused_4 = COALESCE($119, e_confused_4), e_confused_5 = COALESCE($120, e_confused_5), - e_confused_6 = COALESCE($121, e_confused_6), e_confused_7 = COALESCE($122, e_confused_7), e_confused_8 = COALESCE($123, e_confused_8), - e_sleeping_0 = COALESCE($124, e_sleeping_0), e_sleeping_1 = COALESCE($125, e_sleeping_1), e_sleeping_2 = COALESCE($126, e_sleeping_2), - e_sleeping_3 = COALESCE($127, e_sleeping_3), e_sleeping_4 = COALESCE($128, e_sleeping_4), e_sleeping_5 = COALESCE($129, e_sleeping_5), - e_sleeping_6 = COALESCE($130, e_sleeping_6), e_sleeping_7 = COALESCE($131, e_sleeping_7), e_sleeping_8 = COALESCE($132, e_sleeping_8), - e_wink_0 = COALESCE($133, e_wink_0), e_wink_1 = COALESCE($134, e_wink_1), e_wink_2 = COALESCE($135, e_wink_2), - e_wink_3 = COALESCE($136, e_wink_3), e_wink_4 = COALESCE($137, e_wink_4), e_wink_5 = COALESCE($138, e_wink_5), - e_wink_6 = COALESCE($139, e_wink_6), e_wink_7 = COALESCE($140, e_wink_7), e_wink_8 = COALESCE($141, e_wink_8), + l_skin_0 = COALESCE($7, l_skin_0), + l_skin_1 = COALESCE($8, l_skin_1), + l_skin_2 = COALESCE($9, l_skin_2), + l_skin_3 = COALESCE($10, l_skin_3), + l_skin_4 = COALESCE($11, l_skin_4), + l_skin_5 = COALESCE($12, l_skin_5), + l_skin_6 = COALESCE($13, l_skin_6), + l_skin_7 = COALESCE($14, l_skin_7), + l_skin_8 = COALESCE($15, l_skin_8), + l_clothes_0 = COALESCE($16, l_clothes_0), + l_clothes_1 = COALESCE($17, l_clothes_1), + l_clothes_2 = COALESCE($18, l_clothes_2), + l_clothes_3 = COALESCE($19, l_clothes_3), + l_clothes_4 = COALESCE($20, l_clothes_4), + l_clothes_5 = COALESCE($21, l_clothes_5), + l_clothes_6 = COALESCE($22, l_clothes_6), + l_clothes_7 = COALESCE($23, l_clothes_7), + l_clothes_8 = COALESCE($24, l_clothes_8), + l_accessories_0 = COALESCE($25, l_accessories_0), + l_accessories_1 = COALESCE($26, l_accessories_1), + l_accessories_2 = COALESCE($27, l_accessories_2), + l_accessories_3 = COALESCE($28, l_accessories_3), + l_accessories_4 = COALESCE($29, l_accessories_4), + l_accessories_5 = COALESCE($30, l_accessories_5), + l_accessories_6 = COALESCE($31, l_accessories_6), + l_accessories_7 = COALESCE($32, l_accessories_7), + l_accessories_8 = COALESCE($33, l_accessories_8), + e_neutral_0 = COALESCE($34, e_neutral_0), + e_neutral_1 = COALESCE($35, e_neutral_1), + e_neutral_2 = COALESCE($36, e_neutral_2), + e_neutral_3 = COALESCE($37, e_neutral_3), + e_neutral_4 = COALESCE($38, e_neutral_4), + e_neutral_5 = COALESCE($39, e_neutral_5), + e_neutral_6 = COALESCE($40, e_neutral_6), + e_neutral_7 = COALESCE($41, e_neutral_7), + e_neutral_8 = COALESCE($42, e_neutral_8), + e_happy_0 = COALESCE($43, e_happy_0), + e_happy_1 = COALESCE($44, e_happy_1), + e_happy_2 = COALESCE($45, e_happy_2), + e_happy_3 = COALESCE($46, e_happy_3), + e_happy_4 = COALESCE($47, e_happy_4), + e_happy_5 = COALESCE($48, e_happy_5), + e_happy_6 = COALESCE($49, e_happy_6), + e_happy_7 = COALESCE($50, e_happy_7), + e_happy_8 = COALESCE($51, e_happy_8), + e_sad_0 = COALESCE($52, e_sad_0), + e_sad_1 = COALESCE($53, e_sad_1), + e_sad_2 = COALESCE($54, e_sad_2), + e_sad_3 = COALESCE($55, e_sad_3), + e_sad_4 = COALESCE($56, e_sad_4), + e_sad_5 = COALESCE($57, e_sad_5), + e_sad_6 = COALESCE($58, e_sad_6), + e_sad_7 = COALESCE($59, e_sad_7), + e_sad_8 = COALESCE($60, e_sad_8), + e_angry_0 = COALESCE($61, e_angry_0), + e_angry_1 = COALESCE($62, e_angry_1), + e_angry_2 = COALESCE($63, e_angry_2), + e_angry_3 = COALESCE($64, e_angry_3), + e_angry_4 = COALESCE($65, e_angry_4), + e_angry_5 = COALESCE($66, e_angry_5), + e_angry_6 = COALESCE($67, e_angry_6), + e_angry_7 = COALESCE($68, e_angry_7), + e_angry_8 = COALESCE($69, e_angry_8), + e_surprised_0 = COALESCE($70, e_surprised_0), + e_surprised_1 = COALESCE($71, e_surprised_1), + e_surprised_2 = COALESCE($72, e_surprised_2), + e_surprised_3 = COALESCE($73, e_surprised_3), + e_surprised_4 = COALESCE($74, e_surprised_4), + e_surprised_5 = COALESCE($75, e_surprised_5), + e_surprised_6 = COALESCE($76, e_surprised_6), + e_surprised_7 = COALESCE($77, e_surprised_7), + e_surprised_8 = COALESCE($78, e_surprised_8), + e_thinking_0 = COALESCE($79, e_thinking_0), + e_thinking_1 = COALESCE($80, e_thinking_1), + e_thinking_2 = COALESCE($81, e_thinking_2), + e_thinking_3 = COALESCE($82, e_thinking_3), + e_thinking_4 = COALESCE($83, e_thinking_4), + e_thinking_5 = COALESCE($84, e_thinking_5), + e_thinking_6 = COALESCE($85, e_thinking_6), + e_thinking_7 = COALESCE($86, e_thinking_7), + e_thinking_8 = COALESCE($87, e_thinking_8), + e_laughing_0 = COALESCE($88, e_laughing_0), + e_laughing_1 = COALESCE($89, e_laughing_1), + e_laughing_2 = COALESCE($90, e_laughing_2), + e_laughing_3 = COALESCE($91, e_laughing_3), + e_laughing_4 = COALESCE($92, e_laughing_4), + e_laughing_5 = COALESCE($93, e_laughing_5), + e_laughing_6 = COALESCE($94, e_laughing_6), + e_laughing_7 = COALESCE($95, e_laughing_7), + e_laughing_8 = COALESCE($96, e_laughing_8), + e_crying_0 = COALESCE($97, e_crying_0), + e_crying_1 = COALESCE($98, e_crying_1), + e_crying_2 = COALESCE($99, e_crying_2), + e_crying_3 = COALESCE($100, e_crying_3), + e_crying_4 = COALESCE($101, e_crying_4), + e_crying_5 = COALESCE($102, e_crying_5), + e_crying_6 = COALESCE($103, e_crying_6), + e_crying_7 = COALESCE($104, e_crying_7), + e_crying_8 = COALESCE($105, e_crying_8), + e_love_0 = COALESCE($106, e_love_0), + e_love_1 = COALESCE($107, e_love_1), + e_love_2 = COALESCE($108, e_love_2), + e_love_3 = COALESCE($109, e_love_3), + e_love_4 = COALESCE($110, e_love_4), + e_love_5 = COALESCE($111, e_love_5), + e_love_6 = COALESCE($112, e_love_6), + e_love_7 = COALESCE($113, e_love_7), + e_love_8 = COALESCE($114, e_love_8), + e_confused_0 = COALESCE($115, e_confused_0), + e_confused_1 = COALESCE($116, e_confused_1), + e_confused_2 = COALESCE($117, e_confused_2), + e_confused_3 = COALESCE($118, e_confused_3), + e_confused_4 = COALESCE($119, e_confused_4), + e_confused_5 = COALESCE($120, e_confused_5), + e_confused_6 = COALESCE($121, e_confused_6), + e_confused_7 = COALESCE($122, e_confused_7), + e_confused_8 = COALESCE($123, e_confused_8), + e_sleeping_0 = COALESCE($124, e_sleeping_0), + e_sleeping_1 = COALESCE($125, e_sleeping_1), + e_sleeping_2 = COALESCE($126, e_sleeping_2), + e_sleeping_3 = COALESCE($127, e_sleeping_3), + e_sleeping_4 = COALESCE($128, e_sleeping_4), + e_sleeping_5 = COALESCE($129, e_sleeping_5), + e_sleeping_6 = COALESCE($130, e_sleeping_6), + e_sleeping_7 = COALESCE($131, e_sleeping_7), + e_sleeping_8 = COALESCE($132, e_sleeping_8), + e_wink_0 = COALESCE($133, e_wink_0), + e_wink_1 = COALESCE($134, e_wink_1), + e_wink_2 = COALESCE($135, e_wink_2), + e_wink_3 = COALESCE($136, e_wink_3), + e_wink_4 = COALESCE($137, e_wink_4), + e_wink_5 = COALESCE($138, e_wink_5), + e_wink_6 = COALESCE($139, e_wink_6), + e_wink_7 = COALESCE($140, e_wink_7), + e_wink_8 = COALESCE($141, e_wink_8), updated_at = now() WHERE id = $1 RETURNING * @@ -561,65 +915,155 @@ pub async fn update_server_avatar<'e>( .bind(req.is_active) .bind(&req.thumbnail_path) // Skin layer - .bind(req.l_skin_0).bind(req.l_skin_1).bind(req.l_skin_2) - .bind(req.l_skin_3).bind(req.l_skin_4).bind(req.l_skin_5) - .bind(req.l_skin_6).bind(req.l_skin_7).bind(req.l_skin_8) + .bind(req.l_skin_0) + .bind(req.l_skin_1) + .bind(req.l_skin_2) + .bind(req.l_skin_3) + .bind(req.l_skin_4) + .bind(req.l_skin_5) + .bind(req.l_skin_6) + .bind(req.l_skin_7) + .bind(req.l_skin_8) // Clothes layer - .bind(req.l_clothes_0).bind(req.l_clothes_1).bind(req.l_clothes_2) - .bind(req.l_clothes_3).bind(req.l_clothes_4).bind(req.l_clothes_5) - .bind(req.l_clothes_6).bind(req.l_clothes_7).bind(req.l_clothes_8) + .bind(req.l_clothes_0) + .bind(req.l_clothes_1) + .bind(req.l_clothes_2) + .bind(req.l_clothes_3) + .bind(req.l_clothes_4) + .bind(req.l_clothes_5) + .bind(req.l_clothes_6) + .bind(req.l_clothes_7) + .bind(req.l_clothes_8) // Accessories layer - .bind(req.l_accessories_0).bind(req.l_accessories_1).bind(req.l_accessories_2) - .bind(req.l_accessories_3).bind(req.l_accessories_4).bind(req.l_accessories_5) - .bind(req.l_accessories_6).bind(req.l_accessories_7).bind(req.l_accessories_8) + .bind(req.l_accessories_0) + .bind(req.l_accessories_1) + .bind(req.l_accessories_2) + .bind(req.l_accessories_3) + .bind(req.l_accessories_4) + .bind(req.l_accessories_5) + .bind(req.l_accessories_6) + .bind(req.l_accessories_7) + .bind(req.l_accessories_8) // Neutral emotion - .bind(req.e_neutral_0).bind(req.e_neutral_1).bind(req.e_neutral_2) - .bind(req.e_neutral_3).bind(req.e_neutral_4).bind(req.e_neutral_5) - .bind(req.e_neutral_6).bind(req.e_neutral_7).bind(req.e_neutral_8) + .bind(req.e_neutral_0) + .bind(req.e_neutral_1) + .bind(req.e_neutral_2) + .bind(req.e_neutral_3) + .bind(req.e_neutral_4) + .bind(req.e_neutral_5) + .bind(req.e_neutral_6) + .bind(req.e_neutral_7) + .bind(req.e_neutral_8) // Happy emotion - .bind(req.e_happy_0).bind(req.e_happy_1).bind(req.e_happy_2) - .bind(req.e_happy_3).bind(req.e_happy_4).bind(req.e_happy_5) - .bind(req.e_happy_6).bind(req.e_happy_7).bind(req.e_happy_8) + .bind(req.e_happy_0) + .bind(req.e_happy_1) + .bind(req.e_happy_2) + .bind(req.e_happy_3) + .bind(req.e_happy_4) + .bind(req.e_happy_5) + .bind(req.e_happy_6) + .bind(req.e_happy_7) + .bind(req.e_happy_8) // Sad emotion - .bind(req.e_sad_0).bind(req.e_sad_1).bind(req.e_sad_2) - .bind(req.e_sad_3).bind(req.e_sad_4).bind(req.e_sad_5) - .bind(req.e_sad_6).bind(req.e_sad_7).bind(req.e_sad_8) + .bind(req.e_sad_0) + .bind(req.e_sad_1) + .bind(req.e_sad_2) + .bind(req.e_sad_3) + .bind(req.e_sad_4) + .bind(req.e_sad_5) + .bind(req.e_sad_6) + .bind(req.e_sad_7) + .bind(req.e_sad_8) // Angry emotion - .bind(req.e_angry_0).bind(req.e_angry_1).bind(req.e_angry_2) - .bind(req.e_angry_3).bind(req.e_angry_4).bind(req.e_angry_5) - .bind(req.e_angry_6).bind(req.e_angry_7).bind(req.e_angry_8) + .bind(req.e_angry_0) + .bind(req.e_angry_1) + .bind(req.e_angry_2) + .bind(req.e_angry_3) + .bind(req.e_angry_4) + .bind(req.e_angry_5) + .bind(req.e_angry_6) + .bind(req.e_angry_7) + .bind(req.e_angry_8) // Surprised emotion - .bind(req.e_surprised_0).bind(req.e_surprised_1).bind(req.e_surprised_2) - .bind(req.e_surprised_3).bind(req.e_surprised_4).bind(req.e_surprised_5) - .bind(req.e_surprised_6).bind(req.e_surprised_7).bind(req.e_surprised_8) + .bind(req.e_surprised_0) + .bind(req.e_surprised_1) + .bind(req.e_surprised_2) + .bind(req.e_surprised_3) + .bind(req.e_surprised_4) + .bind(req.e_surprised_5) + .bind(req.e_surprised_6) + .bind(req.e_surprised_7) + .bind(req.e_surprised_8) // Thinking emotion - .bind(req.e_thinking_0).bind(req.e_thinking_1).bind(req.e_thinking_2) - .bind(req.e_thinking_3).bind(req.e_thinking_4).bind(req.e_thinking_5) - .bind(req.e_thinking_6).bind(req.e_thinking_7).bind(req.e_thinking_8) + .bind(req.e_thinking_0) + .bind(req.e_thinking_1) + .bind(req.e_thinking_2) + .bind(req.e_thinking_3) + .bind(req.e_thinking_4) + .bind(req.e_thinking_5) + .bind(req.e_thinking_6) + .bind(req.e_thinking_7) + .bind(req.e_thinking_8) // Laughing emotion - .bind(req.e_laughing_0).bind(req.e_laughing_1).bind(req.e_laughing_2) - .bind(req.e_laughing_3).bind(req.e_laughing_4).bind(req.e_laughing_5) - .bind(req.e_laughing_6).bind(req.e_laughing_7).bind(req.e_laughing_8) + .bind(req.e_laughing_0) + .bind(req.e_laughing_1) + .bind(req.e_laughing_2) + .bind(req.e_laughing_3) + .bind(req.e_laughing_4) + .bind(req.e_laughing_5) + .bind(req.e_laughing_6) + .bind(req.e_laughing_7) + .bind(req.e_laughing_8) // Crying emotion - .bind(req.e_crying_0).bind(req.e_crying_1).bind(req.e_crying_2) - .bind(req.e_crying_3).bind(req.e_crying_4).bind(req.e_crying_5) - .bind(req.e_crying_6).bind(req.e_crying_7).bind(req.e_crying_8) + .bind(req.e_crying_0) + .bind(req.e_crying_1) + .bind(req.e_crying_2) + .bind(req.e_crying_3) + .bind(req.e_crying_4) + .bind(req.e_crying_5) + .bind(req.e_crying_6) + .bind(req.e_crying_7) + .bind(req.e_crying_8) // Love emotion - .bind(req.e_love_0).bind(req.e_love_1).bind(req.e_love_2) - .bind(req.e_love_3).bind(req.e_love_4).bind(req.e_love_5) - .bind(req.e_love_6).bind(req.e_love_7).bind(req.e_love_8) + .bind(req.e_love_0) + .bind(req.e_love_1) + .bind(req.e_love_2) + .bind(req.e_love_3) + .bind(req.e_love_4) + .bind(req.e_love_5) + .bind(req.e_love_6) + .bind(req.e_love_7) + .bind(req.e_love_8) // Confused emotion - .bind(req.e_confused_0).bind(req.e_confused_1).bind(req.e_confused_2) - .bind(req.e_confused_3).bind(req.e_confused_4).bind(req.e_confused_5) - .bind(req.e_confused_6).bind(req.e_confused_7).bind(req.e_confused_8) + .bind(req.e_confused_0) + .bind(req.e_confused_1) + .bind(req.e_confused_2) + .bind(req.e_confused_3) + .bind(req.e_confused_4) + .bind(req.e_confused_5) + .bind(req.e_confused_6) + .bind(req.e_confused_7) + .bind(req.e_confused_8) // Sleeping emotion - .bind(req.e_sleeping_0).bind(req.e_sleeping_1).bind(req.e_sleeping_2) - .bind(req.e_sleeping_3).bind(req.e_sleeping_4).bind(req.e_sleeping_5) - .bind(req.e_sleeping_6).bind(req.e_sleeping_7).bind(req.e_sleeping_8) + .bind(req.e_sleeping_0) + .bind(req.e_sleeping_1) + .bind(req.e_sleeping_2) + .bind(req.e_sleeping_3) + .bind(req.e_sleeping_4) + .bind(req.e_sleeping_5) + .bind(req.e_sleeping_6) + .bind(req.e_sleeping_7) + .bind(req.e_sleeping_8) // Wink emotion - .bind(req.e_wink_0).bind(req.e_wink_1).bind(req.e_wink_2) - .bind(req.e_wink_3).bind(req.e_wink_4).bind(req.e_wink_5) - .bind(req.e_wink_6).bind(req.e_wink_7).bind(req.e_wink_8) + .bind(req.e_wink_0) + .bind(req.e_wink_1) + .bind(req.e_wink_2) + .bind(req.e_wink_3) + .bind(req.e_wink_4) + .bind(req.e_wink_5) + .bind(req.e_wink_6) + .bind(req.e_wink_7) + .bind(req.e_wink_8) .fetch_one(executor) .await?; diff --git a/crates/chattyness-db/src/queries/spots.rs b/crates/chattyness-db/src/queries/spots.rs index 3b6fa7b..7160c9f 100644 --- a/crates/chattyness-db/src/queries/spots.rs +++ b/crates/chattyness-db/src/queries/spots.rs @@ -4,7 +4,7 @@ use sqlx::PgExecutor; use uuid::Uuid; use crate::models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest}; -use chattyness_error::{AppError, OptionExt}; +use chattyness_error::AppError; /// List all spots for a scene. pub async fn list_spots_for_scene<'e>( @@ -289,7 +289,7 @@ pub async fn update_spot<'e>( let spot = query_builder .fetch_optional(executor) .await? - .or_not_found("Spot")?; + .ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; Ok(spot) } diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 5b7bc83..2951fba 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -104,42 +104,6 @@ pub enum ClientMessage { /// Request to refresh identity after registration (guest → user conversion). /// Server will fetch updated user data and broadcast to all members. RefreshIdentity, - - /// Update a loose prop's scale (moderator only). - UpdateProp { - /// The loose prop ID to update. - loose_prop_id: Uuid, - /// New scale factor (0.1 - 10.0). - scale: f32, - }, - - /// Move a loose prop to a new position. - MoveProp { - /// The loose prop ID to move. - loose_prop_id: Uuid, - /// New X coordinate in scene space. - x: f64, - /// New Y coordinate in scene space. - y: f64, - }, - - /// Lock a loose prop (moderator only). - LockProp { - /// The loose prop ID to lock. - loose_prop_id: Uuid, - }, - - /// Unlock a loose prop (moderator only). - UnlockProp { - /// The loose prop ID to unlock. - loose_prop_id: Uuid, - }, - - /// Permanently delete a prop from inventory (does not drop to scene). - DeleteProp { - /// Inventory item ID to delete. - inventory_item_id: Uuid, - }, } /// Server-to-client WebSocket messages. @@ -257,18 +221,6 @@ pub enum ServerMessage { prop_id: Uuid, }, - /// A prop was permanently deleted from inventory. - PropDeleted { - /// ID of the deleted inventory item. - inventory_item_id: Uuid, - }, - - /// A prop was updated (scale changed) - clients should update their local copy. - PropRefresh { - /// The updated prop with all current values. - prop: LooseProp, - }, - /// A member updated their avatar appearance. AvatarUpdated { /// User ID of the member. diff --git a/crates/chattyness-error/src/lib.rs b/crates/chattyness-error/src/lib.rs index d516d19..fb6e4ff 100644 --- a/crates/chattyness-error/src/lib.rs +++ b/crates/chattyness-error/src/lib.rs @@ -1,27 +1,6 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; -/// Extension trait for Option to convert to AppError::NotFound. -/// -/// Reduces boilerplate when fetching entities that may not exist. -/// -/// # Example -/// ```rust -/// use chattyness_error::OptionExt; -/// -/// let scene = get_scene_by_id(&pool, id).await?.or_not_found("Scene")?; -/// ``` -pub trait OptionExt { - /// Convert None to AppError::NotFound with a descriptive message. - fn or_not_found(self, entity: &str) -> Result; -} - -impl OptionExt for Option { - fn or_not_found(self, entity: &str) -> Result { - self.ok_or_else(|| AppError::NotFound(format!("{} not found", entity))) - } -} - /// Application error types for chattyness. /// /// All errors derive From for automatic conversion where applicable. diff --git a/crates/chattyness-user-ui/src/api/scenes.rs b/crates/chattyness-user-ui/src/api/scenes.rs index 8d9f857..d83f99a 100644 --- a/crates/chattyness-user-ui/src/api/scenes.rs +++ b/crates/chattyness-user-ui/src/api/scenes.rs @@ -11,7 +11,7 @@ use chattyness_db::{ models::{Scene, SceneSummary, Spot, SpotSummary}, queries::{realms, scenes, spots}, }; -use chattyness_error::{AppError, OptionExt}; +use chattyness_error::AppError; /// Get the entry scene for a realm. /// @@ -86,7 +86,7 @@ pub async fn get_spot( ) -> Result, AppError> { let spot = spots::get_spot_by_id(&pool, spot_id) .await? - .or_not_found("Spot")?; + .ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; Ok(Json(spot)) } diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index c053192..108034b 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -19,7 +19,7 @@ use uuid::Uuid; use chattyness_db::{ models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, ForcedAvatarReason, User}, - queries::{avatars, channel_members, inventory, loose_props, memberships, moderation, realm_avatars, realms, scenes, server_avatars, users}, + queries::{avatars, channel_members, loose_props, memberships, moderation, realm_avatars, realms, scenes, server_avatars, users}, ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig}, }; use chattyness_error::AppError; @@ -711,57 +711,7 @@ async fn handle_socket( } } } - ClientMessage::DeleteProp { inventory_item_id } => { - match inventory::drop_inventory_item( - &mut *recv_conn, - user_id, - inventory_item_id, - ) - .await - { - Ok(()) => { - let _ = direct_tx.send(ServerMessage::PropDeleted { - inventory_item_id, - }).await; - } - Err(e) => { - let (code, message) = match &e { - chattyness_error::AppError::Forbidden(msg) => ( - "PROP_NOT_DELETABLE".to_string(), - msg.clone(), - ), - chattyness_error::AppError::NotFound(msg) => { - ("PROP_NOT_FOUND".to_string(), msg.clone()) - } - _ => ( - "DELETE_FAILED".to_string(), - format!("{:?}", e), - ), - }; - let _ = direct_tx.send(ServerMessage::Error { code, message }).await; - } - } - } ClientMessage::PickUpProp { loose_prop_id } => { - // Check if prop is locked - let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await { - Ok(result) => result, - Err(e) => { - tracing::error!("[WS] Failed to check moderator status: {:?}", e); - false - } - }; - - if let Ok(Some(prop)) = loose_props::get_loose_prop_by_id(&mut *recv_conn, loose_prop_id).await { - if prop.is_locked && !is_mod { - let _ = direct_tx.send(ServerMessage::Error { - code: "PROP_LOCKED".to_string(), - message: "This prop is locked and cannot be picked up".to_string(), - }).await; - continue; - } - } - match loose_props::pick_up_loose_prop( &mut *recv_conn, loose_prop_id, @@ -1469,195 +1419,6 @@ async fn handle_socket( } } } - ClientMessage::UpdateProp { loose_prop_id, scale } => { - // Check if user is a moderator - let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await { - Ok(result) => result, - Err(e) => { - tracing::error!("[WS] Failed to check moderator status: {:?}", e); - false - } - }; - - if !is_mod { - let _ = direct_tx.send(ServerMessage::Error { - code: "NOT_MODERATOR".to_string(), - message: "You do not have permission to update props".to_string(), - }).await; - continue; - } - - // Check if prop is locked (for non-mods) - if let Ok(Some(prop)) = loose_props::get_loose_prop_by_id(&mut *recv_conn, loose_prop_id).await { - if prop.is_locked && !is_mod { - let _ = direct_tx.send(ServerMessage::Error { - code: "PROP_LOCKED".to_string(), - message: "This prop is locked and cannot be modified".to_string(), - }).await; - continue; - } - } - - // Update the prop scale - match loose_props::update_loose_prop_scale( - &mut *recv_conn, - loose_prop_id, - scale, - ).await { - Ok(updated_prop) => { - #[cfg(debug_assertions)] - tracing::debug!( - "[WS] User {} updated prop {} scale to {}", - user_id, - loose_prop_id, - scale - ); - // Broadcast the updated prop to all users in the channel - let _ = tx.send(ServerMessage::PropRefresh { prop: updated_prop }); - } - Err(e) => { - tracing::error!("[WS] Update prop failed: {:?}", e); - let _ = direct_tx.send(ServerMessage::Error { - code: "UPDATE_PROP_FAILED".to_string(), - message: format!("{:?}", e), - }).await; - } - } - } - ClientMessage::MoveProp { loose_prop_id, x, y } => { - // Check if user is a moderator (needed for locked props) - let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await { - Ok(result) => result, - Err(e) => { - tracing::error!("[WS] Failed to check moderator status: {:?}", e); - false - } - }; - - // Check if prop is locked - if let Ok(Some(prop)) = loose_props::get_loose_prop_by_id(&mut *recv_conn, loose_prop_id).await { - if prop.is_locked && !is_mod { - let _ = direct_tx.send(ServerMessage::Error { - code: "PROP_LOCKED".to_string(), - message: "This prop is locked and cannot be moved".to_string(), - }).await; - continue; - } - } - - // Move the prop - match loose_props::move_loose_prop( - &mut *recv_conn, - loose_prop_id, - x, - y, - ).await { - Ok(updated_prop) => { - #[cfg(debug_assertions)] - tracing::debug!( - "[WS] User {} moved prop {} to ({}, {})", - user_id, - loose_prop_id, - x, - y - ); - // Broadcast the updated prop to all users in the channel - let _ = tx.send(ServerMessage::PropRefresh { prop: updated_prop }); - } - Err(e) => { - tracing::error!("[WS] Move prop failed: {:?}", e); - let _ = direct_tx.send(ServerMessage::Error { - code: "MOVE_PROP_FAILED".to_string(), - message: format!("{:?}", e), - }).await; - } - } - } - ClientMessage::LockProp { loose_prop_id } => { - // Check if user is a moderator - let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await { - Ok(result) => result, - Err(e) => { - tracing::error!("[WS] Failed to check moderator status: {:?}", e); - false - } - }; - - if !is_mod { - let _ = direct_tx.send(ServerMessage::Error { - code: "NOT_MODERATOR".to_string(), - message: "You do not have permission to lock props".to_string(), - }).await; - continue; - } - - // Lock the prop - match loose_props::lock_loose_prop( - &mut *recv_conn, - loose_prop_id, - user_id, - ).await { - Ok(updated_prop) => { - #[cfg(debug_assertions)] - tracing::debug!( - "[WS] User {} locked prop {}", - user_id, - loose_prop_id - ); - // Broadcast the updated prop to all users in the channel - let _ = tx.send(ServerMessage::PropRefresh { prop: updated_prop }); - } - Err(e) => { - tracing::error!("[WS] Lock prop failed: {:?}", e); - let _ = direct_tx.send(ServerMessage::Error { - code: "LOCK_PROP_FAILED".to_string(), - message: format!("{:?}", e), - }).await; - } - } - } - ClientMessage::UnlockProp { loose_prop_id } => { - // Check if user is a moderator - let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await { - Ok(result) => result, - Err(e) => { - tracing::error!("[WS] Failed to check moderator status: {:?}", e); - false - } - }; - - if !is_mod { - let _ = direct_tx.send(ServerMessage::Error { - code: "NOT_MODERATOR".to_string(), - message: "You do not have permission to unlock props".to_string(), - }).await; - continue; - } - - // Unlock the prop - match loose_props::unlock_loose_prop( - &mut *recv_conn, - loose_prop_id, - ).await { - Ok(updated_prop) => { - #[cfg(debug_assertions)] - tracing::debug!( - "[WS] User {} unlocked prop {}", - user_id, - loose_prop_id - ); - // Broadcast the updated prop to all users in the channel - let _ = tx.send(ServerMessage::PropRefresh { prop: updated_prop }); - } - Err(e) => { - tracing::error!("[WS] Unlock prop failed: {:?}", e); - let _ = direct_tx.send(ServerMessage::Error { - code: "UNLOCK_PROP_FAILED".to_string(), - message: format!("{:?}", e), - }).await; - } - } - } } } Message::Close(close_frame) => { diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index 4fd3459..057c5ce 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -2,7 +2,6 @@ pub mod avatar_canvas; pub mod avatar_editor; -pub mod canvas_utils; pub mod avatar_store; pub mod avatar_thumbnail; pub mod chat; @@ -18,7 +17,6 @@ pub mod keybindings; pub mod keybindings_popup; pub mod layout; pub mod log_popup; -pub mod loose_prop_canvas; pub mod modals; pub mod notifications; pub mod register_modal; @@ -33,7 +31,6 @@ pub mod ws_client; pub use avatar_canvas::*; pub use avatar_editor::*; pub use avatar_store::*; -pub use canvas_utils::*; pub use avatar_thumbnail::*; pub use chat::*; pub use chat_types::*; @@ -48,7 +45,6 @@ pub use keybindings::*; pub use keybindings_popup::*; pub use layout::*; pub use log_popup::*; -pub use loose_prop_canvas::*; pub use modals::*; pub use notifications::*; pub use register_modal::*; diff --git a/crates/chattyness-user-ui/src/components/avatar_canvas.rs b/crates/chattyness-user-ui/src/components/avatar_canvas.rs index d35f114..1ff8b87 100644 --- a/crates/chattyness-user-ui/src/components/avatar_canvas.rs +++ b/crates/chattyness-user-ui/src/components/avatar_canvas.rs @@ -9,10 +9,6 @@ use uuid::Uuid; use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState}; -#[cfg(feature = "hydrate")] -pub use super::canvas_utils::hit_test_canvas; -#[cfg(feature = "hydrate")] -use super::canvas_utils::normalize_asset_path; use super::chat_types::{ActiveBubble, emotion_bubble_colors}; /// Base text size multiplier. Text at 100% slider = base_sizes * 1.4 @@ -806,6 +802,15 @@ pub fn AvatarCanvas( } } +/// Normalize an asset path to be absolute, prefixing with /static/ if needed. +#[cfg(feature = "hydrate")] +fn normalize_asset_path(path: &str) -> String { + if path.starts_with('/') { + path.to_string() + } else { + format!("/static/{}", path) + } +} /// Draw a speech bubble using the unified CanvasLayout. /// @@ -967,6 +972,68 @@ fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64 lines } +/// Test if a click at the given client coordinates hits a non-transparent pixel. +/// +/// Returns true if the alpha channel at the clicked pixel is > 0. +/// This enables pixel-perfect hit detection on avatar canvases. +#[cfg(feature = "hydrate")] +pub fn hit_test_canvas(canvas: &web_sys::HtmlCanvasElement, client_x: f64, client_y: f64) -> bool { + use wasm_bindgen::JsCast; + + // Get the canvas bounding rect to transform client coords to canvas coords + let rect = canvas.get_bounding_client_rect(); + + // Calculate click position relative to the canvas element + let relative_x = client_x - rect.left(); + let relative_y = client_y - rect.top(); + + // Check if click is within canvas bounds + if relative_x < 0.0 + || relative_y < 0.0 + || relative_x >= rect.width() + || relative_y >= rect.height() + { + return false; + } + + // Transform to canvas pixel coordinates (accounting for CSS scaling) + let canvas_width = canvas.width() as f64; + let canvas_height = canvas.height() as f64; + + // Avoid division by zero + if rect.width() == 0.0 || rect.height() == 0.0 { + return false; + } + + let scale_x = canvas_width / rect.width(); + let scale_y = canvas_height / rect.height(); + + let pixel_x = (relative_x * scale_x) as f64; + let pixel_y = (relative_y * scale_y) as f64; + + // Get the 2D context and read the pixel data using JavaScript interop + if let Ok(Some(ctx)) = canvas.get_context("2d") { + let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); + + // Use web_sys::CanvasRenderingContext2d::get_image_data with proper error handling + match ctx.get_image_data(pixel_x, pixel_y, 1.0, 1.0) { + Ok(image_data) => { + // Get the pixel data as Clamped> + let data = image_data.data(); + // Alpha channel is the 4th value (index 3) + if data.len() >= 4 { + return data[3] > 0; + } + } + Err(_) => { + // Security error or other issue with getImageData - assume no hit + return false; + } + } + } + + false +} /// Draw a rounded rectangle path. #[cfg(feature = "hydrate")] diff --git a/crates/chattyness-user-ui/src/components/canvas_utils.rs b/crates/chattyness-user-ui/src/components/canvas_utils.rs deleted file mode 100644 index dbfcd8d..0000000 --- a/crates/chattyness-user-ui/src/components/canvas_utils.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Shared canvas utilities for avatar and prop rendering. -//! -//! Common functions used by both AvatarCanvas and LoosePropCanvas components. - -/// Normalize an asset path to be absolute, prefixing with /static/ if needed. -#[cfg(feature = "hydrate")] -pub fn normalize_asset_path(path: &str) -> String { - if path.starts_with('/') { - path.to_string() - } else { - format!("/static/{}", path) - } -} - -/// Test if a click at the given client coordinates hits a non-transparent pixel. -/// -/// Returns true if the alpha channel at the clicked pixel is > 0. -/// This enables pixel-perfect hit detection on canvas elements. -#[cfg(feature = "hydrate")] -pub fn hit_test_canvas( - canvas: &web_sys::HtmlCanvasElement, - client_x: f64, - client_y: f64, -) -> bool { - use wasm_bindgen::JsCast; - - // Get the canvas bounding rect to transform client coords to canvas coords - let rect = canvas.get_bounding_client_rect(); - - // Calculate click position relative to the canvas element - let relative_x = client_x - rect.left(); - let relative_y = client_y - rect.top(); - - // Check if click is within canvas bounds - if relative_x < 0.0 - || relative_y < 0.0 - || relative_x >= rect.width() - || relative_y >= rect.height() - { - return false; - } - - // Transform to canvas pixel coordinates (accounting for CSS scaling) - let canvas_width = canvas.width() as f64; - let canvas_height = canvas.height() as f64; - - // Avoid division by zero - if rect.width() == 0.0 || rect.height() == 0.0 { - return false; - } - - let scale_x = canvas_width / rect.width(); - let scale_y = canvas_height / rect.height(); - - let pixel_x = relative_x * scale_x; - let pixel_y = relative_y * scale_y; - - // Get the 2D context and read the pixel data - if let Ok(Some(ctx)) = canvas.get_context("2d") { - let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap(); - - match ctx.get_image_data(pixel_x, pixel_y, 1.0, 1.0) { - Ok(image_data) => { - let data = image_data.data(); - // Alpha channel is the 4th value (index 3) - if data.len() >= 4 { - return data[3] > 0; - } - } - Err(_) => { - return false; - } - } - } - - false -} diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index ff18d4f..67fa342 100644 --- a/crates/chattyness-user-ui/src/components/inventory.rs +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -8,7 +8,7 @@ use chattyness_db::models::{InventoryItem, PropAcquisitionInfo}; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; -use super::modals::{ConfirmModal, GuestLockedOverlay, Modal}; +use super::modals::{GuestLockedOverlay, Modal}; use super::tabs::{Tab, TabBar}; use super::ws_client::WsSender; @@ -45,8 +45,6 @@ pub fn InventoryPopup( let (error, set_error) = signal(Option::::None); let (selected_item, set_selected_item) = signal(Option::::None); let (dropping, set_dropping) = signal(false); - let (deleting, set_deleting) = signal(false); - let (delete_confirm_item, set_delete_confirm_item) = signal(Option::<(Uuid, String)>::None); // Server props state (with acquisition info for authenticated users) let (server_props, set_server_props) = signal(Vec::::new()); @@ -246,33 +244,6 @@ pub fn InventoryPopup( #[cfg(not(feature = "hydrate"))] let handle_drop = |_item_id: Uuid| {}; - // Handle delete action via WebSocket (permanent deletion) - #[cfg(feature = "hydrate")] - let handle_delete = { - move |item_id: Uuid| { - set_deleting.set(true); - ws_sender.with_value(|sender| { - if let Some(send_fn) = sender { - send_fn(ClientMessage::DeleteProp { - inventory_item_id: item_id, - }); - // Optimistically remove from local list - set_items.update(|items| { - items.retain(|i| i.id != item_id); - }); - set_selected_item.set(None); - set_delete_confirm_item.set(None); - } else { - set_error.set(Some("Not connected to server".to_string())); - } - }); - set_deleting.set(false); - } - }; - - #[cfg(not(feature = "hydrate"))] - let handle_delete = |_item_id: Uuid| {}; - view! { @@ -357,25 +324,6 @@ pub fn InventoryPopup( - - // Delete confirmation modal - {move || { - delete_confirm_item.get().map(|(item_id, item_name)| { - view! { - - } - }) - }} } @@ -391,8 +339,6 @@ fn MyInventoryTab( set_selected_item: WriteSignal>, #[prop(into)] dropping: Signal, #[prop(into)] on_drop: Callback, - #[prop(into)] deleting: Signal, - #[prop(into)] on_delete_request: Callback<(Uuid, String)>, ) -> impl IntoView { view! { // Loading state @@ -477,11 +423,8 @@ fn MyInventoryTab( let item_id = selected_item.get()?; let item = items.get().into_iter().find(|i| i.id == item_id)?; let on_drop = on_drop.clone(); - let on_delete_request = on_delete_request.clone(); let is_dropping = dropping.get(); - let is_deleting = deleting.get(); let is_droppable = item.is_droppable; - let item_name = item.prop_name.clone(); Some(view! {
@@ -509,25 +452,10 @@ fn MyInventoryTab( } } disabled=is_dropping || !is_droppable - title=if is_droppable { "Drop prop to scene canvas" } else { "Essential prop cannot be dropped" } + title=if is_droppable { "" } else { "Essential prop cannot be dropped" } > {if is_dropping { "Dropping..." } else { "Drop" }} - // Delete button - only shown for droppable props - - - // Transfer button (disabled for now)