diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index d462ac7..f8bbeb6 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -820,6 +820,28 @@ pub struct Avatar { pub e_confused_7: Option, pub e_confused_8: Option, + // Emotion: Sleeping (e10) + pub e_sleeping_0: Option, + pub e_sleeping_1: Option, + pub e_sleeping_2: Option, + pub e_sleeping_3: Option, + pub e_sleeping_4: Option, + pub e_sleeping_5: Option, + pub e_sleeping_6: Option, + pub e_sleeping_7: Option, + pub e_sleeping_8: Option, + + // Emotion: Wink (e11) + pub e_wink_0: Option, + pub e_wink_1: Option, + pub e_wink_2: Option, + pub e_wink_3: Option, + pub e_wink_4: Option, + pub e_wink_5: Option, + pub e_wink_6: Option, + pub e_wink_7: Option, + pub e_wink_8: Option, + pub created_at: DateTime, pub updated_at: DateTime, } @@ -1722,3 +1744,84 @@ impl Default for EmotionAvailability { } } } + +// ============================================================================= +// Full Avatar with Paths +// ============================================================================= + +/// Full avatar data with all inventory UUIDs resolved to asset paths. +/// +/// This struct contains all 135 slots (27 content layer + 108 emotion layer) +/// with paths pre-resolved, enabling client-side emotion availability computation +/// and rendering without additional server queries. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvatarWithPaths { + pub avatar_id: Uuid, + pub current_emotion: i16, + /// Asset paths for skin layer positions 0-8 + pub skin_layer: [Option; 9], + /// Asset paths for clothes layer positions 0-8 + pub clothes_layer: [Option; 9], + /// Asset paths for accessories layer positions 0-8 + pub accessories_layer: [Option; 9], + /// Asset paths for all 12 emotions, each with 9 positions. + /// Index: emotions[emotion_index][position] where emotion_index is 0-11 + /// (neutral, happy, sad, angry, surprised, thinking, laughing, crying, love, confused, sleeping, wink) + pub emotions: [[Option; 9]; 12], + /// Whether each emotion has at least one slot populated (UUID exists, even if path lookup failed). + /// This matches the old get_emotion_availability behavior. + pub emotions_available: [bool; 12], +} + +impl Default for AvatarWithPaths { + fn default() -> Self { + Self { + avatar_id: Uuid::nil(), + current_emotion: 0, + skin_layer: Default::default(), + clothes_layer: Default::default(), + accessories_layer: Default::default(), + emotions: Default::default(), + emotions_available: [false; 12], + } + } +} + +impl AvatarWithPaths { + /// Compute emotion availability from the avatar data. + /// Uses pre-computed emotions_available which checks if UUIDs exist (not just resolved paths). + pub fn compute_emotion_availability(&self) -> EmotionAvailability { + let mut preview_paths: [Option; 12] = Default::default(); + + for (i, emotion_layer) in self.emotions.iter().enumerate() { + // Preview is position 4 (center) + preview_paths[i] = emotion_layer[4].clone(); + } + + EmotionAvailability { + available: self.emotions_available, + preview_paths, + } + } + + /// Get the 9-path emotion layer for a specific emotion index (0-11). + pub fn get_emotion_layer(&self, emotion: usize) -> [Option; 9] { + if emotion < 12 { + self.emotions[emotion].clone() + } else { + Default::default() + } + } + + /// Convert to AvatarRenderData for the current emotion. + pub fn to_render_data(&self) -> AvatarRenderData { + AvatarRenderData { + avatar_id: self.avatar_id, + current_emotion: self.current_emotion, + skin_layer: self.skin_layer.clone(), + clothes_layer: self.clothes_layer.clone(), + accessories_layer: self.accessories_layer.clone(), + emotion_layer: self.get_emotion_layer(self.current_emotion as usize), + } + } +} diff --git a/crates/chattyness-db/src/queries/avatars.rs b/crates/chattyness-db/src/queries/avatars.rs index e4bda9f..45d4051 100644 --- a/crates/chattyness-db/src/queries/avatars.rs +++ b/crates/chattyness-db/src/queries/avatars.rs @@ -1,9 +1,11 @@ //! Avatar-related database queries. -use sqlx::PgExecutor; +use std::collections::HashMap; + +use sqlx::{postgres::PgConnection, PgExecutor, PgPool}; use uuid::Uuid; -use crate::models::{ActiveAvatar, AvatarRenderData, EmotionAvailability}; +use crate::models::{ActiveAvatar, AvatarWithPaths, EmotionAvailability}; use chattyness_error::AppError; /// Get the active avatar for a user in a realm. @@ -112,96 +114,6 @@ struct EmotionLayerRow { p8: Option, } -/// Get render data for a user's avatar in a realm. -/// -/// Returns the asset paths for all equipped props in the avatar's current state. -/// This is a simplified version that only returns the center position (position 4) -/// props for skin, clothes, accessories, and current emotion layers. -pub async fn get_avatar_render_data<'e>( - executor: impl PgExecutor<'e>, - user_id: Uuid, - realm_id: Uuid, -) -> Result { - // Simplified query: just get position 4 (center) props for each layer - // This covers the common case of simple face avatars - let render_data = sqlx::query_as::<_, SimplifiedAvatarRow>( - r#" - SELECT - a.id as avatar_id, - aa.current_emotion, - -- Skin layer center - skin.prop_asset_path as skin_center, - -- Clothes layer center - clothes.prop_asset_path as clothes_center, - -- Accessories layer center - acc.prop_asset_path as accessories_center, - -- Current emotion layer center (based on current_emotion) - CASE aa.current_emotion - WHEN 0 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_neutral_4) - WHEN 1 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_happy_4) - WHEN 2 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_sad_4) - WHEN 3 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_angry_4) - WHEN 4 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_surprised_4) - WHEN 5 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_thinking_4) - WHEN 6 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_laughing_4) - WHEN 7 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_crying_4) - WHEN 8 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_love_4) - WHEN 9 THEN (SELECT prop_asset_path FROM props.inventory WHERE id = a.e_confused_4) - END as emotion_center - FROM props.active_avatars aa - JOIN props.avatars a ON aa.avatar_id = a.id - LEFT JOIN props.inventory skin ON a.l_skin_4 = skin.id - LEFT JOIN props.inventory clothes ON a.l_clothes_4 = clothes.id - LEFT JOIN props.inventory acc ON a.l_accessories_4 = acc.id - WHERE aa.user_id = $1 AND aa.realm_id = $2 - "#, - ) - .bind(user_id) - .bind(realm_id) - .fetch_optional(executor) - .await?; - - match render_data { - Some(row) => Ok(row.into()), - None => Ok(AvatarRenderData::default()), - } -} - -/// Simplified avatar row for center-only rendering. -#[derive(Debug, sqlx::FromRow)] -struct SimplifiedAvatarRow { - avatar_id: Uuid, - current_emotion: i16, - skin_center: Option, - clothes_center: Option, - accessories_center: Option, - emotion_center: Option, -} - -impl From for AvatarRenderData { - fn from(row: SimplifiedAvatarRow) -> Self { - // For now, only populate position 4 (center) - let mut skin_layer: [Option; 9] = Default::default(); - let mut clothes_layer: [Option; 9] = Default::default(); - let mut accessories_layer: [Option; 9] = Default::default(); - let mut emotion_layer: [Option; 9] = Default::default(); - - skin_layer[4] = row.skin_center; - clothes_layer[4] = row.clothes_center; - accessories_layer[4] = row.accessories_center; - emotion_layer[4] = row.emotion_center; - - Self { - avatar_id: row.avatar_id, - current_emotion: row.current_emotion, - skin_layer, - clothes_layer, - accessories_layer, - emotion_layer, - } - } -} - /// Get emotion availability for a user's avatar in a realm. /// /// Returns which emotions have assets configured (any of positions 0-8 non-null) @@ -365,3 +277,688 @@ impl From for EmotionAvailability { } } } + +/// Get the full avatar with all inventory UUIDs resolved to asset paths. +/// +/// This function uses two queries: +/// 1. Fetch the avatar row with all 135 UUID slots +/// 2. Bulk resolve all UUIDs to asset paths with a single inventory query +/// +/// The result enables client-side emotion availability computation and rendering. +pub async fn get_avatar_with_paths( + pool: &PgPool, + user_id: Uuid, + realm_id: Uuid, +) -> Result, AppError> { + // Query 1: Get the avatar row with current_emotion from active_avatars + let avatar_row = sqlx::query_as::<_, AvatarWithEmotion>( + r#" + SELECT + a.*, + aa.current_emotion + FROM props.active_avatars aa + JOIN props.avatars a ON aa.avatar_id = a.id + WHERE aa.user_id = $1 AND aa.realm_id = $2 + "#, + ) + .bind(user_id) + .bind(realm_id) + .fetch_optional(pool) + .await?; + + let Some(avatar) = avatar_row else { + return Ok(None); + }; + + // Collect all non-null UUIDs from the avatar slots + let mut uuids: Vec = Vec::new(); + + // Content layers + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + + // Emotion layers (12 emotions × 9 positions) + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + + // Query 2: Bulk resolve all UUIDs to paths + let paths: HashMap = if uuids.is_empty() { + HashMap::new() + } else { + sqlx::query_as::<_, (Uuid, String)>( + "SELECT id, prop_asset_path FROM props.inventory WHERE id = ANY($1)", + ) + .bind(&uuids) + .fetch_all(pool) + .await? + .into_iter() + .collect() + }; + + // Build the AvatarWithPaths + let resolve = |uuid: Option| -> Option { + uuid.and_then(|id| paths.get(&id).cloned()) + }; + + // Check if any UUID in the array is non-null (emotion is available) + let has_any = |slots: &[Option]| -> bool { + slots.iter().any(|u| u.is_some()) + }; + + // Compute emotions_available from UUID presence (not path resolution) + let emotions_available = [ + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + ]; + + Ok(Some(AvatarWithPaths { + avatar_id: avatar.id, + current_emotion: avatar.current_emotion, + skin_layer: [ + resolve(avatar.l_skin_0), resolve(avatar.l_skin_1), resolve(avatar.l_skin_2), + resolve(avatar.l_skin_3), resolve(avatar.l_skin_4), resolve(avatar.l_skin_5), + resolve(avatar.l_skin_6), resolve(avatar.l_skin_7), resolve(avatar.l_skin_8), + ], + clothes_layer: [ + resolve(avatar.l_clothes_0), resolve(avatar.l_clothes_1), resolve(avatar.l_clothes_2), + resolve(avatar.l_clothes_3), resolve(avatar.l_clothes_4), resolve(avatar.l_clothes_5), + resolve(avatar.l_clothes_6), resolve(avatar.l_clothes_7), resolve(avatar.l_clothes_8), + ], + accessories_layer: [ + resolve(avatar.l_accessories_0), resolve(avatar.l_accessories_1), resolve(avatar.l_accessories_2), + resolve(avatar.l_accessories_3), resolve(avatar.l_accessories_4), resolve(avatar.l_accessories_5), + resolve(avatar.l_accessories_6), resolve(avatar.l_accessories_7), resolve(avatar.l_accessories_8), + ], + emotions: [ + // Neutral (0) + [ + resolve(avatar.e_neutral_0), resolve(avatar.e_neutral_1), resolve(avatar.e_neutral_2), + resolve(avatar.e_neutral_3), resolve(avatar.e_neutral_4), resolve(avatar.e_neutral_5), + resolve(avatar.e_neutral_6), resolve(avatar.e_neutral_7), resolve(avatar.e_neutral_8), + ], + // Happy (1) + [ + resolve(avatar.e_happy_0), resolve(avatar.e_happy_1), resolve(avatar.e_happy_2), + resolve(avatar.e_happy_3), resolve(avatar.e_happy_4), resolve(avatar.e_happy_5), + resolve(avatar.e_happy_6), resolve(avatar.e_happy_7), resolve(avatar.e_happy_8), + ], + // Sad (2) + [ + resolve(avatar.e_sad_0), resolve(avatar.e_sad_1), resolve(avatar.e_sad_2), + resolve(avatar.e_sad_3), resolve(avatar.e_sad_4), resolve(avatar.e_sad_5), + resolve(avatar.e_sad_6), resolve(avatar.e_sad_7), resolve(avatar.e_sad_8), + ], + // Angry (3) + [ + resolve(avatar.e_angry_0), resolve(avatar.e_angry_1), resolve(avatar.e_angry_2), + resolve(avatar.e_angry_3), resolve(avatar.e_angry_4), resolve(avatar.e_angry_5), + resolve(avatar.e_angry_6), resolve(avatar.e_angry_7), resolve(avatar.e_angry_8), + ], + // Surprised (4) + [ + resolve(avatar.e_surprised_0), resolve(avatar.e_surprised_1), resolve(avatar.e_surprised_2), + resolve(avatar.e_surprised_3), resolve(avatar.e_surprised_4), resolve(avatar.e_surprised_5), + resolve(avatar.e_surprised_6), resolve(avatar.e_surprised_7), resolve(avatar.e_surprised_8), + ], + // Thinking (5) + [ + resolve(avatar.e_thinking_0), resolve(avatar.e_thinking_1), resolve(avatar.e_thinking_2), + resolve(avatar.e_thinking_3), resolve(avatar.e_thinking_4), resolve(avatar.e_thinking_5), + resolve(avatar.e_thinking_6), resolve(avatar.e_thinking_7), resolve(avatar.e_thinking_8), + ], + // Laughing (6) + [ + resolve(avatar.e_laughing_0), resolve(avatar.e_laughing_1), resolve(avatar.e_laughing_2), + resolve(avatar.e_laughing_3), resolve(avatar.e_laughing_4), resolve(avatar.e_laughing_5), + resolve(avatar.e_laughing_6), resolve(avatar.e_laughing_7), resolve(avatar.e_laughing_8), + ], + // Crying (7) + [ + resolve(avatar.e_crying_0), resolve(avatar.e_crying_1), resolve(avatar.e_crying_2), + resolve(avatar.e_crying_3), resolve(avatar.e_crying_4), resolve(avatar.e_crying_5), + resolve(avatar.e_crying_6), resolve(avatar.e_crying_7), resolve(avatar.e_crying_8), + ], + // Love (8) + [ + resolve(avatar.e_love_0), resolve(avatar.e_love_1), resolve(avatar.e_love_2), + resolve(avatar.e_love_3), resolve(avatar.e_love_4), resolve(avatar.e_love_5), + resolve(avatar.e_love_6), resolve(avatar.e_love_7), resolve(avatar.e_love_8), + ], + // Confused (9) + [ + resolve(avatar.e_confused_0), resolve(avatar.e_confused_1), resolve(avatar.e_confused_2), + resolve(avatar.e_confused_3), resolve(avatar.e_confused_4), resolve(avatar.e_confused_5), + resolve(avatar.e_confused_6), resolve(avatar.e_confused_7), resolve(avatar.e_confused_8), + ], + // Sleeping (10) + [ + resolve(avatar.e_sleeping_0), resolve(avatar.e_sleeping_1), resolve(avatar.e_sleeping_2), + resolve(avatar.e_sleeping_3), resolve(avatar.e_sleeping_4), resolve(avatar.e_sleeping_5), + resolve(avatar.e_sleeping_6), resolve(avatar.e_sleeping_7), resolve(avatar.e_sleeping_8), + ], + // Wink (11) + [ + resolve(avatar.e_wink_0), resolve(avatar.e_wink_1), resolve(avatar.e_wink_2), + resolve(avatar.e_wink_3), resolve(avatar.e_wink_4), resolve(avatar.e_wink_5), + resolve(avatar.e_wink_6), resolve(avatar.e_wink_7), resolve(avatar.e_wink_8), + ], + ], + emotions_available, + })) +} + +/// Get full avatar with all inventory UUIDs resolved to asset paths (connection variant). +/// +/// This variant accepts a mutable connection reference for use with RLS-enabled connections. +pub async fn get_avatar_with_paths_conn( + conn: &mut PgConnection, + user_id: Uuid, + realm_id: Uuid, +) -> Result, AppError> { + // Query 1: Get the avatar row with current_emotion from active_avatars + let avatar_row = sqlx::query_as::<_, AvatarWithEmotion>( + r#" + SELECT + a.*, + aa.current_emotion + FROM props.active_avatars aa + JOIN props.avatars a ON aa.avatar_id = a.id + WHERE aa.user_id = $1 AND aa.realm_id = $2 + "#, + ) + .bind(user_id) + .bind(realm_id) + .fetch_optional(&mut *conn) + .await?; + + let Some(avatar) = avatar_row else { + return Ok(None); + }; + + // Collect all non-null UUIDs from the avatar slots + let mut uuids: Vec = Vec::new(); + + // Content layers + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + + // Emotion layers (12 emotions × 9 positions) + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + collect_uuids(&mut uuids, &[ + 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, + ]); + + // Query 2: Bulk resolve all UUIDs to paths + let paths: HashMap = if uuids.is_empty() { + HashMap::new() + } else { + sqlx::query_as::<_, (Uuid, String)>( + "SELECT id, prop_asset_path FROM props.inventory WHERE id = ANY($1)", + ) + .bind(&uuids) + .fetch_all(&mut *conn) + .await? + .into_iter() + .collect() + }; + + // Build the AvatarWithPaths + let resolve = |uuid: Option| -> Option { + uuid.and_then(|id| paths.get(&id).cloned()) + }; + + // Check if any UUID in the array is non-null (emotion is available) + let has_any = |slots: &[Option]| -> bool { + slots.iter().any(|u| u.is_some()) + }; + + // Compute emotions_available from UUID presence (not path resolution) + let emotions_available = [ + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + has_any(&[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]), + ]; + + Ok(Some(AvatarWithPaths { + avatar_id: avatar.id, + current_emotion: avatar.current_emotion, + skin_layer: [ + resolve(avatar.l_skin_0), resolve(avatar.l_skin_1), resolve(avatar.l_skin_2), + resolve(avatar.l_skin_3), resolve(avatar.l_skin_4), resolve(avatar.l_skin_5), + resolve(avatar.l_skin_6), resolve(avatar.l_skin_7), resolve(avatar.l_skin_8), + ], + clothes_layer: [ + resolve(avatar.l_clothes_0), resolve(avatar.l_clothes_1), resolve(avatar.l_clothes_2), + resolve(avatar.l_clothes_3), resolve(avatar.l_clothes_4), resolve(avatar.l_clothes_5), + resolve(avatar.l_clothes_6), resolve(avatar.l_clothes_7), resolve(avatar.l_clothes_8), + ], + accessories_layer: [ + resolve(avatar.l_accessories_0), resolve(avatar.l_accessories_1), resolve(avatar.l_accessories_2), + resolve(avatar.l_accessories_3), resolve(avatar.l_accessories_4), resolve(avatar.l_accessories_5), + resolve(avatar.l_accessories_6), resolve(avatar.l_accessories_7), resolve(avatar.l_accessories_8), + ], + emotions: [ + // Neutral (0) + [ + resolve(avatar.e_neutral_0), resolve(avatar.e_neutral_1), resolve(avatar.e_neutral_2), + resolve(avatar.e_neutral_3), resolve(avatar.e_neutral_4), resolve(avatar.e_neutral_5), + resolve(avatar.e_neutral_6), resolve(avatar.e_neutral_7), resolve(avatar.e_neutral_8), + ], + // Happy (1) + [ + resolve(avatar.e_happy_0), resolve(avatar.e_happy_1), resolve(avatar.e_happy_2), + resolve(avatar.e_happy_3), resolve(avatar.e_happy_4), resolve(avatar.e_happy_5), + resolve(avatar.e_happy_6), resolve(avatar.e_happy_7), resolve(avatar.e_happy_8), + ], + // Sad (2) + [ + resolve(avatar.e_sad_0), resolve(avatar.e_sad_1), resolve(avatar.e_sad_2), + resolve(avatar.e_sad_3), resolve(avatar.e_sad_4), resolve(avatar.e_sad_5), + resolve(avatar.e_sad_6), resolve(avatar.e_sad_7), resolve(avatar.e_sad_8), + ], + // Angry (3) + [ + resolve(avatar.e_angry_0), resolve(avatar.e_angry_1), resolve(avatar.e_angry_2), + resolve(avatar.e_angry_3), resolve(avatar.e_angry_4), resolve(avatar.e_angry_5), + resolve(avatar.e_angry_6), resolve(avatar.e_angry_7), resolve(avatar.e_angry_8), + ], + // Surprised (4) + [ + resolve(avatar.e_surprised_0), resolve(avatar.e_surprised_1), resolve(avatar.e_surprised_2), + resolve(avatar.e_surprised_3), resolve(avatar.e_surprised_4), resolve(avatar.e_surprised_5), + resolve(avatar.e_surprised_6), resolve(avatar.e_surprised_7), resolve(avatar.e_surprised_8), + ], + // Thinking (5) + [ + resolve(avatar.e_thinking_0), resolve(avatar.e_thinking_1), resolve(avatar.e_thinking_2), + resolve(avatar.e_thinking_3), resolve(avatar.e_thinking_4), resolve(avatar.e_thinking_5), + resolve(avatar.e_thinking_6), resolve(avatar.e_thinking_7), resolve(avatar.e_thinking_8), + ], + // Laughing (6) + [ + resolve(avatar.e_laughing_0), resolve(avatar.e_laughing_1), resolve(avatar.e_laughing_2), + resolve(avatar.e_laughing_3), resolve(avatar.e_laughing_4), resolve(avatar.e_laughing_5), + resolve(avatar.e_laughing_6), resolve(avatar.e_laughing_7), resolve(avatar.e_laughing_8), + ], + // Crying (7) + [ + resolve(avatar.e_crying_0), resolve(avatar.e_crying_1), resolve(avatar.e_crying_2), + resolve(avatar.e_crying_3), resolve(avatar.e_crying_4), resolve(avatar.e_crying_5), + resolve(avatar.e_crying_6), resolve(avatar.e_crying_7), resolve(avatar.e_crying_8), + ], + // Love (8) + [ + resolve(avatar.e_love_0), resolve(avatar.e_love_1), resolve(avatar.e_love_2), + resolve(avatar.e_love_3), resolve(avatar.e_love_4), resolve(avatar.e_love_5), + resolve(avatar.e_love_6), resolve(avatar.e_love_7), resolve(avatar.e_love_8), + ], + // Confused (9) + [ + resolve(avatar.e_confused_0), resolve(avatar.e_confused_1), resolve(avatar.e_confused_2), + resolve(avatar.e_confused_3), resolve(avatar.e_confused_4), resolve(avatar.e_confused_5), + resolve(avatar.e_confused_6), resolve(avatar.e_confused_7), resolve(avatar.e_confused_8), + ], + // Sleeping (10) + [ + resolve(avatar.e_sleeping_0), resolve(avatar.e_sleeping_1), resolve(avatar.e_sleeping_2), + resolve(avatar.e_sleeping_3), resolve(avatar.e_sleeping_4), resolve(avatar.e_sleeping_5), + resolve(avatar.e_sleeping_6), resolve(avatar.e_sleeping_7), resolve(avatar.e_sleeping_8), + ], + // Wink (11) + [ + resolve(avatar.e_wink_0), resolve(avatar.e_wink_1), resolve(avatar.e_wink_2), + resolve(avatar.e_wink_3), resolve(avatar.e_wink_4), resolve(avatar.e_wink_5), + resolve(avatar.e_wink_6), resolve(avatar.e_wink_7), resolve(avatar.e_wink_8), + ], + ], + emotions_available, + })) +} + +/// Helper to collect non-null UUIDs into a Vec. +fn collect_uuids(dest: &mut Vec, sources: &[Option]) { + for uuid in sources { + if let Some(id) = uuid { + dest.push(*id); + } + } +} + +/// Avatar row with current_emotion from active_avatars join. +#[derive(Debug, sqlx::FromRow)] +struct AvatarWithEmotion { + pub id: Uuid, + pub current_emotion: i16, + // Content layers + pub l_skin_0: Option, + pub l_skin_1: Option, + pub l_skin_2: Option, + pub l_skin_3: Option, + pub l_skin_4: Option, + pub l_skin_5: Option, + pub l_skin_6: Option, + pub l_skin_7: Option, + pub l_skin_8: Option, + pub l_clothes_0: Option, + pub l_clothes_1: Option, + pub l_clothes_2: Option, + pub l_clothes_3: Option, + pub l_clothes_4: Option, + pub l_clothes_5: Option, + pub l_clothes_6: Option, + pub l_clothes_7: Option, + pub l_clothes_8: Option, + pub l_accessories_0: Option, + pub l_accessories_1: Option, + pub l_accessories_2: Option, + pub l_accessories_3: Option, + pub l_accessories_4: Option, + pub l_accessories_5: Option, + pub l_accessories_6: Option, + pub l_accessories_7: Option, + pub l_accessories_8: Option, + // Emotion layers + pub e_neutral_0: Option, + pub e_neutral_1: Option, + pub e_neutral_2: Option, + pub e_neutral_3: Option, + pub e_neutral_4: Option, + pub e_neutral_5: Option, + pub e_neutral_6: Option, + pub e_neutral_7: Option, + pub e_neutral_8: Option, + pub e_happy_0: Option, + pub e_happy_1: Option, + pub e_happy_2: Option, + pub e_happy_3: Option, + pub e_happy_4: Option, + pub e_happy_5: Option, + pub e_happy_6: Option, + pub e_happy_7: Option, + pub e_happy_8: Option, + pub e_sad_0: Option, + pub e_sad_1: Option, + pub e_sad_2: Option, + pub e_sad_3: Option, + pub e_sad_4: Option, + pub e_sad_5: Option, + pub e_sad_6: Option, + pub e_sad_7: Option, + pub e_sad_8: Option, + pub e_angry_0: Option, + pub e_angry_1: Option, + pub e_angry_2: Option, + pub e_angry_3: Option, + pub e_angry_4: Option, + pub e_angry_5: Option, + pub e_angry_6: Option, + pub e_angry_7: Option, + pub e_angry_8: Option, + pub e_surprised_0: Option, + pub e_surprised_1: Option, + pub e_surprised_2: Option, + pub e_surprised_3: Option, + pub e_surprised_4: Option, + pub e_surprised_5: Option, + pub e_surprised_6: Option, + pub e_surprised_7: Option, + pub e_surprised_8: Option, + pub e_thinking_0: Option, + pub e_thinking_1: Option, + pub e_thinking_2: Option, + pub e_thinking_3: Option, + pub e_thinking_4: Option, + pub e_thinking_5: Option, + pub e_thinking_6: Option, + pub e_thinking_7: Option, + pub e_thinking_8: Option, + pub e_laughing_0: Option, + pub e_laughing_1: Option, + pub e_laughing_2: Option, + pub e_laughing_3: Option, + pub e_laughing_4: Option, + pub e_laughing_5: Option, + pub e_laughing_6: Option, + pub e_laughing_7: Option, + pub e_laughing_8: Option, + pub e_crying_0: Option, + pub e_crying_1: Option, + pub e_crying_2: Option, + pub e_crying_3: Option, + pub e_crying_4: Option, + pub e_crying_5: Option, + pub e_crying_6: Option, + pub e_crying_7: Option, + pub e_crying_8: Option, + pub e_love_0: Option, + pub e_love_1: Option, + pub e_love_2: Option, + pub e_love_3: Option, + pub e_love_4: Option, + pub e_love_5: Option, + pub e_love_6: Option, + pub e_love_7: Option, + pub e_love_8: Option, + pub e_confused_0: Option, + pub e_confused_1: Option, + pub e_confused_2: Option, + pub e_confused_3: Option, + pub e_confused_4: Option, + pub e_confused_5: Option, + pub e_confused_6: Option, + pub e_confused_7: Option, + pub e_confused_8: Option, + pub e_sleeping_0: Option, + pub e_sleeping_1: Option, + pub e_sleeping_2: Option, + pub e_sleeping_3: Option, + pub e_sleeping_4: Option, + pub e_sleeping_5: Option, + pub e_sleeping_6: Option, + pub e_sleeping_7: Option, + pub e_sleeping_8: Option, + pub e_wink_0: Option, + pub e_wink_1: Option, + pub e_wink_2: Option, + pub e_wink_3: Option, + pub e_wink_4: Option, + pub e_wink_5: Option, + pub e_wink_6: Option, + pub e_wink_7: Option, + pub e_wink_8: Option, +} + +/// Set the current emotion for a user (simplified - no path lookup). +/// +/// This is used when the user's client has the full avatar cached locally +/// and can render the emotion from its local cache. +pub async fn set_emotion_simple<'e>( + executor: impl PgExecutor<'e>, + user_id: Uuid, + realm_id: Uuid, + emotion: i16, +) -> Result<(), AppError> { + if emotion < 0 || emotion > 11 { + return Err(AppError::Validation("Emotion must be 0-11".to_string())); + } + + let result = sqlx::query( + r#" + UPDATE props.active_avatars + SET current_emotion = $3, updated_at = now() + WHERE user_id = $1 AND realm_id = $2 + "#, + ) + .bind(user_id) + .bind(realm_id) + .bind(emotion) + .execute(executor) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound( + "No active avatar for this user in this realm".to_string(), + )); + } + + Ok(()) +} diff --git a/crates/chattyness-db/src/ws_messages.rs b/crates/chattyness-db/src/ws_messages.rs index 217b080..df9bf76 100644 --- a/crates/chattyness-db/src/ws_messages.rs +++ b/crates/chattyness-db/src/ws_messages.rs @@ -27,6 +27,12 @@ pub enum ClientMessage { /// Ping to keep connection alive. Ping, + + /// Send a chat message to the channel. + SendChatMessage { + /// Message content (max 500 chars). + content: String, + }, } /// Server-to-client WebSocket messages. @@ -89,4 +95,26 @@ pub enum ServerMessage { /// Error message. message: String, }, + + /// A chat message was received. + ChatMessageReceived { + /// Unique message ID. + message_id: Uuid, + /// User ID of sender (if authenticated user). + user_id: Option, + /// Guest session ID (if guest). + guest_session_id: Option, + /// Display name of sender. + display_name: String, + /// Message content. + content: String, + /// Current emotion of sender (0-11) for bubble styling. + emotion: u8, + /// Sender's X position at time of message. + x: f64, + /// Sender's Y position at time of message. + y: f64, + /// Server timestamp (milliseconds since epoch). + timestamp: i64, + }, } diff --git a/crates/chattyness-user-ui/src/api/avatars.rs b/crates/chattyness-user-ui/src/api/avatars.rs index 06c4f02..355b029 100644 --- a/crates/chattyness-user-ui/src/api/avatars.rs +++ b/crates/chattyness-user-ui/src/api/avatars.rs @@ -1,61 +1,42 @@ //! Avatar API handlers for user UI. //! -//! Handles avatar rendering data retrieval. -//! Note: Emotion switching is now handled via WebSocket. +//! Handles avatar data retrieval. +//! Note: Emotion switching is handled via WebSocket. -use axum::{ - extract::{Path, State}, - Json, -}; -use sqlx::PgPool; +use axum::extract::Path; +use axum::Json; use chattyness_db::{ - models::{AvatarRenderData, EmotionAvailability}, + models::AvatarWithPaths, queries::{avatars, realms}, }; use chattyness_error::AppError; -use crate::auth::AuthUser; +use crate::auth::{AuthUser, RlsConn}; -/// Get current avatar render data. +/// Get full avatar with all paths resolved. /// -/// GET /api/realms/{slug}/avatar/current +/// GET /api/realms/{slug}/avatar /// -/// Returns the render data for the user's active avatar in this realm. -pub async fn get_current_avatar( - State(pool): State, +/// Returns the complete avatar data with all inventory UUIDs resolved to asset paths. +/// This enables client-side emotion availability computation and rendering without +/// additional server queries. +pub async fn get_avatar( + rls_conn: RlsConn, AuthUser(user): AuthUser, Path(slug): Path, -) -> Result, AppError> { +) -> Result, AppError> { + let mut conn = rls_conn.acquire().await; + // Get realm - let realm = realms::get_realm_by_slug(&pool, &slug) + let realm = realms::get_realm_by_slug(&mut *conn, &slug) .await? .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; - // Get render data - let render_data = avatars::get_avatar_render_data(&pool, user.id, realm.id).await?; - - Ok(Json(render_data)) -} - -/// Get emotion availability for the user's avatar. -/// -/// GET /api/realms/{slug}/avatar/emotions -/// -/// Returns which emotions are available (have configured assets) for the user's -/// active avatar in this realm, along with preview paths for the emotion picker UI. -pub async fn get_emotion_availability( - State(pool): State, - AuthUser(user): AuthUser, - Path(slug): Path, -) -> Result, AppError> { - // Get realm - let realm = realms::get_realm_by_slug(&pool, &slug) + // Get full avatar with paths + let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm.id) .await? - .ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?; + .unwrap_or_default(); - // Get emotion availability - let availability = avatars::get_emotion_availability(&pool, user.id, realm.id).await?; - - Ok(Json(availability)) + Ok(Json(avatar)) } diff --git a/crates/chattyness-user-ui/src/api/routes.rs b/crates/chattyness-user-ui/src/api/routes.rs index c74d5e1..be68309 100644 --- a/crates/chattyness-user-ui/src/api/routes.rs +++ b/crates/chattyness-user-ui/src/api/routes.rs @@ -50,12 +50,5 @@ pub fn api_router() -> Router { get(websocket::ws_handler::), ) // Avatar routes (require authentication) - .route( - "/realms/{slug}/avatar/current", - get(avatars::get_current_avatar), - ) - .route( - "/realms/{slug}/avatar/emotions", - get(avatars::get_emotion_availability), - ) + .route("/realms/{slug}/avatar", get(avatars::get_avatar)) } diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index f73ddad..ca05f63 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -191,7 +191,7 @@ async fn handle_socket( tracing::info!("[WS] Channel joined"); // Get initial state - let members = match get_members_with_avatars(&mut *conn, channel_id, realm_id).await { + let members = match get_members_with_avatars(&mut conn, channel_id, realm_id).await { Ok(m) => m, Err(e) => { tracing::error!("[WS] Failed to get members: {:?}", e); @@ -231,8 +231,11 @@ async fn handle_socket( } // Broadcast join to others - let avatar = avatars::get_avatar_render_data(&mut *conn, user.id, realm_id) + let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id) .await + .ok() + .flatten() + .map(|a| a.to_render_data()) .unwrap_or_default(); let join_msg = ServerMessage::MemberJoined { member: ChannelMemberWithAvatar { member, avatar }, @@ -322,6 +325,36 @@ async fn handle_socket( // Respond with pong directly (not broadcast) // This is handled in the send task via individual message } + ClientMessage::SendChatMessage { content } => { + // Validate message + if content.is_empty() || content.len() > 500 { + continue; + } + + // Get member's current position and emotion + let member_info = channel_members::get_channel_member( + &mut *recv_conn, + channel_id, + user_id, + realm_id, + ) + .await; + + if let Ok(Some(member)) = member_info { + let msg = ServerMessage::ChatMessageReceived { + message_id: Uuid::new_v4(), + user_id: Some(user_id), + guest_session_id: None, + display_name: member.display_name.clone(), + content, + emotion: member.current_emotion as u8, + x: member.position_x, + y: member.position_y, + timestamp: chrono::Utc::now().timestamp_millis(), + }; + let _ = tx.send(msg); + } + } } } } @@ -376,25 +409,32 @@ async fn handle_socket( } /// Helper: Get all channel members with their avatar render data. -async fn get_members_with_avatars<'e>( - executor: impl sqlx::PgExecutor<'e>, +async fn get_members_with_avatars( + conn: &mut sqlx::pool::PoolConnection, channel_id: Uuid, realm_id: Uuid, ) -> Result, AppError> { - // Get members first, then we need to get avatars - // But executor is consumed by the first query, so we need the pool - // Actually, let's just inline this to avoid the complexity - let members = channel_members::get_channel_members(executor, channel_id, realm_id).await?; + // Get members first + let members = channel_members::get_channel_members(&mut **conn, channel_id, realm_id).await?; - // For avatar data, we'll just return default for now since the query - // would need another executor - let result: Vec = members - .into_iter() - .map(|member| ChannelMemberWithAvatar { - member, - avatar: AvatarRenderData::default(), - }) - .collect(); + // Fetch avatar data for each member using full avatar with paths + // This avoids the CASE statement approach and handles all emotions correctly + let mut result = Vec::with_capacity(members.len()); + for member in members { + let avatar = if let Some(user_id) = member.user_id { + // Get full avatar and convert to render data for current emotion + avatars::get_avatar_with_paths_conn(&mut **conn, user_id, realm_id) + .await + .ok() + .flatten() + .map(|a| a.to_render_data()) + .unwrap_or_default() + } else { + // Guest users don't have avatars + AvatarRenderData::default() + }; + result.push(ChannelMemberWithAvatar { member, avatar }); + } Ok(result) } diff --git a/crates/chattyness-user-ui/src/components.rs b/crates/chattyness-user-ui/src/components.rs index a1c74bb..e356f67 100644 --- a/crates/chattyness-user-ui/src/components.rs +++ b/crates/chattyness-user-ui/src/components.rs @@ -1,6 +1,7 @@ //! Reusable UI components. pub mod chat; +pub mod chat_types; pub mod editor; pub mod forms; pub mod layout; @@ -9,6 +10,7 @@ pub mod scene_viewer; pub mod ws_client; pub use chat::*; +pub use chat_types::*; pub use editor::*; pub use forms::*; pub use layout::*; diff --git a/crates/chattyness-user-ui/src/components/chat.rs b/crates/chattyness-user-ui/src/components/chat.rs index 1870820..1bda14e 100644 --- a/crates/chattyness-user-ui/src/components/chat.rs +++ b/crates/chattyness-user-ui/src/components/chat.rs @@ -163,6 +163,17 @@ pub fn ChatInput( apply_emotion(emotion_idx); ev.prevent_default(); } + } else if !msg.trim().is_empty() { + // Send regular chat message + ws_sender.with_value(|sender| { + if let Some(send_fn) = sender { + send_fn(ClientMessage::SendChatMessage { + content: msg.trim().to_string(), + }); + } + }); + set_message.set(String::new()); + ev.prevent_default(); } } } @@ -282,7 +293,7 @@ fn EmoteListPopup( aria-label="Available emotions" >
"Select an emotion:"
-
+
)| *idx @@ -301,7 +312,7 @@ fn EmoteListPopup( emotion_path=preview_path.clone() /> - ":" + ":e " {emotion_name} diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 64ca9b5..4d4f7ae 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -4,10 +4,15 @@ //! - Background canvas: Static, drawn once when scene loads //! - Avatar canvas: Dynamic, redrawn when members change +use std::collections::HashMap; + use leptos::prelude::*; +use uuid::Uuid; use chattyness_db::models::{ChannelMemberWithAvatar, Scene}; +use super::chat_types::{emotion_bubble_colors, ActiveBubble}; + /// Parse bounds WKT to extract width and height. /// /// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))" @@ -59,6 +64,8 @@ pub fn RealmSceneViewer( #[prop(into)] members: Signal>, #[prop(into)] + active_bubbles: Signal, Option), ActiveBubble>>, + #[prop(into)] on_move: Callback<(f64, f64)>, ) -> impl IntoView { let dimensions = parse_bounds_dimensions(&scene.bounds_wkt); @@ -225,11 +232,12 @@ pub fn RealmSceneViewer( }); // ========================================================= - // Avatar Effect - runs when members change, redraws avatars only + // Avatar Effect - runs when members or bubbles change // ========================================================= Effect::new(move |_| { - // Track members signal - this Effect reruns when members change + // Track both signals - this Effect reruns when either changes let current_members = members.get(); + let current_bubbles = active_bubbles.get(); let Some(canvas) = avatar_canvas_ref.get() else { return; @@ -265,8 +273,21 @@ pub fn RealmSceneViewer( let ox = offset_x.get_value(); let oy = offset_y.get_value(); - // Draw avatars + // Draw avatars first draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy); + + // Draw speech bubbles on top + let current_time = js_sys::Date::now() as i64; + draw_speech_bubbles( + &ctx, + ¤t_members, + ¤t_bubbles, + sx, + sy, + ox, + oy, + current_time, + ); } }) as Box); @@ -419,3 +440,166 @@ fn draw_avatars( let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * scale_y); } } + +/// Draw speech bubbles above avatars. +#[cfg(feature = "hydrate")] +fn draw_speech_bubbles( + ctx: &web_sys::CanvasRenderingContext2d, + members: &[ChannelMemberWithAvatar], + bubbles: &HashMap<(Option, Option), ActiveBubble>, + scale_x: f64, + scale_y: f64, + offset_x: f64, + offset_y: f64, + current_time_ms: i64, +) { + let scale = scale_x.min(scale_y); + let avatar_size = 48.0 * scale; + let max_bubble_width = 200.0 * scale; + let padding = 8.0 * scale; + let font_size = 12.0 * scale; + let line_height = 16.0 * scale; + let tail_size = 8.0 * scale; + let border_radius = 8.0 * scale; + + for member in members { + let key = (member.member.user_id, member.member.guest_session_id); + + if let Some(bubble) = bubbles.get(&key) { + // Skip expired bubbles + if bubble.expires_at < current_time_ms { + continue; + } + + // Use member's CURRENT position, not message position + let x = member.member.position_x * scale_x + offset_x; + let y = member.member.position_y * scale_y + offset_y; + + // Get emotion colors + let (bg_color, border_color, text_color) = + emotion_bubble_colors(bubble.message.emotion); + + // Measure and wrap text + ctx.set_font(&format!("{}px sans-serif", font_size)); + let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0); + + // Calculate bubble dimensions + let bubble_width = lines + .iter() + .map(|line: &String| -> f64 { + ctx.measure_text(line) + .map(|m: web_sys::TextMetrics| m.width()) + .unwrap_or(0.0) + }) + .fold(0.0_f64, |a: f64, b: f64| a.max(b)) + + padding * 2.0; + let bubble_width = bubble_width.max(60.0 * scale); // Minimum width + let bubble_height = (lines.len() as f64) * line_height + padding * 2.0; + + // Position bubble above avatar + let bubble_x = x - bubble_width / 2.0; + let bubble_y = y - avatar_size - bubble_height - tail_size - 5.0 * scale; + + // Draw bubble background with rounded corners + draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius); + ctx.set_fill_style_str(bg_color); + ctx.fill(); + ctx.set_stroke_style_str(border_color); + ctx.set_line_width(2.0); + ctx.stroke(); + + // Draw tail (triangle pointing down) + ctx.begin_path(); + ctx.move_to(x - tail_size, bubble_y + bubble_height); + ctx.line_to(x, bubble_y + bubble_height + tail_size); + ctx.line_to(x + tail_size, bubble_y + bubble_height); + ctx.close_path(); + ctx.set_fill_style_str(bg_color); + ctx.fill(); + ctx.set_stroke_style_str(border_color); + ctx.stroke(); + + // Draw text + ctx.set_fill_style_str(text_color); + ctx.set_text_align("left"); + ctx.set_text_baseline("top"); + for (i, line) in lines.iter().enumerate() { + let _ = ctx.fill_text( + line, + bubble_x + padding, + bubble_y + padding + (i as f64) * line_height, + ); + } + } + } +} + +/// Wrap text to fit within max_width. +#[cfg(feature = "hydrate")] +fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64) -> Vec { + let words: Vec<&str> = text.split_whitespace().collect(); + let mut lines = Vec::new(); + let mut current_line = String::new(); + + for word in words { + let test_line = if current_line.is_empty() { + word.to_string() + } else { + format!("{} {}", current_line, word) + }; + + let width = ctx + .measure_text(&test_line) + .map(|m: web_sys::TextMetrics| m.width()) + .unwrap_or(0.0); + + if width > max_width && !current_line.is_empty() { + lines.push(current_line); + current_line = word.to_string(); + } else { + current_line = test_line; + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + + // Limit to 4 lines max + if lines.len() > 4 { + lines.truncate(3); + if let Some(last) = lines.last_mut() { + last.push_str("..."); + } + } + + // Handle empty text + if lines.is_empty() { + lines.push(text.to_string()); + } + + lines +} + +/// Draw a rounded rectangle path. +#[cfg(feature = "hydrate")] +fn draw_rounded_rect( + ctx: &web_sys::CanvasRenderingContext2d, + x: f64, + y: f64, + width: f64, + height: f64, + radius: f64, +) { + ctx.begin_path(); + ctx.move_to(x + radius, y); + ctx.line_to(x + width - radius, y); + ctx.arc_to(x + width, y, x + width, y + radius, radius).ok(); + ctx.line_to(x + width, y + height - radius); + ctx.arc_to(x + width, y + height, x + width - radius, y + height, radius).ok(); + ctx.line_to(x + radius, y + height); + ctx.arc_to(x, y + height, x, y + height - radius, radius).ok(); + ctx.line_to(x, y + radius); + ctx.arc_to(x, y, x + radius, y, radius).ok(); + ctx.close_path(); +} diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index f51676e..39cf2f4 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -9,6 +9,8 @@ use leptos::reactive::owner::LocalStorage; use chattyness_db::models::ChannelMemberWithAvatar; use chattyness_db::ws_messages::{ClientMessage, ServerMessage}; +use super::chat_types::ChatMessage; + /// WebSocket connection state. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum WsState { @@ -38,6 +40,7 @@ pub fn use_channel_websocket( realm_slug: Signal, channel_id: Signal>, on_members_update: Callback>, + on_chat_message: Callback, ) -> (Signal, WsSenderStorage) { use std::cell::RefCell; use std::rc::Rc; @@ -129,6 +132,7 @@ pub fn use_channel_websocket( // onmessage let members_for_msg = members_clone.clone(); let on_members_update_clone = on_members_update.clone(); + let on_chat_message_clone = on_chat_message.clone(); let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| { if let Ok(text) = e.data().dyn_into::() { let text: String = text.into(); @@ -136,7 +140,12 @@ pub fn use_channel_websocket( web_sys::console::log_1(&format!("[WS<-Server] {}", text).into()); if let Ok(msg) = serde_json::from_str::(&text) { - handle_server_message(msg, &members_for_msg, &on_members_update_clone); + handle_server_message( + msg, + &members_for_msg, + &on_members_update_clone, + &on_chat_message_clone, + ); } } }) as Box); @@ -177,6 +186,7 @@ fn handle_server_message( msg: ServerMessage, members: &std::rc::Rc>>, on_update: &Callback>, + on_chat_message: &Callback, ) { let mut members_vec = members.borrow_mut(); @@ -241,6 +251,30 @@ fn handle_server_message( #[cfg(debug_assertions)] web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into()); } + ServerMessage::ChatMessageReceived { + message_id, + user_id, + guest_session_id, + display_name, + content, + emotion, + x, + y, + timestamp, + } => { + let chat_msg = ChatMessage { + message_id, + user_id, + guest_session_id, + display_name, + content, + emotion, + x, + y, + timestamp, + }; + on_chat_message.run(chat_msg); + } } } @@ -250,6 +284,7 @@ pub fn use_channel_websocket( _realm_slug: Signal, _channel_id: Signal>, _on_members_update: Callback>, + _on_chat_message: Callback, ) -> (Signal, WsSenderStorage) { let (ws_state, _) = signal(WsState::Disconnected); let sender: WsSenderStorage = StoredValue::new_local(None); diff --git a/crates/chattyness-user-ui/src/pages/realm.rs b/crates/chattyness-user-ui/src/pages/realm.rs index 66befd7..00089ab 100644 --- a/crates/chattyness-user-ui/src/pages/realm.rs +++ b/crates/chattyness-user-ui/src/pages/realm.rs @@ -1,5 +1,7 @@ //! Realm landing page after login. +use std::collections::HashMap; + use leptos::prelude::*; use leptos::reactive::owner::LocalStorage; #[cfg(feature = "hydrate")] @@ -7,12 +9,17 @@ use leptos::task::spawn_local; #[cfg(feature = "hydrate")] use leptos_router::hooks::use_navigate; use leptos_router::hooks::use_params_map; +use uuid::Uuid; -use crate::components::{Card, ChatInput, RealmHeader, RealmSceneViewer}; +use crate::components::{ + ActiveBubble, Card, ChatInput, ChatMessage, MessageLog, RealmHeader, RealmSceneViewer, + DEFAULT_BUBBLE_TIMEOUT_MS, +}; #[cfg(feature = "hydrate")] use crate::components::use_channel_websocket; use chattyness_db::models::{ - ChannelMemberWithAvatar, EmotionAvailability, RealmRole, RealmWithUserRole, Scene, + AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, RealmRole, RealmWithUserRole, + Scene, }; #[cfg(feature = "hydrate")] use chattyness_db::ws_messages::ClientMessage; @@ -42,6 +49,12 @@ pub fn RealmPage() -> impl IntoView { // Skin preview path for emote picker (position 4 of skin layer) let (skin_preview_path, set_skin_preview_path) = signal(Option::::None); + // Chat message state - use StoredValue for WASM compatibility (single-threaded) + let message_log: StoredValue = + StoredValue::new_local(MessageLog::new()); + let (active_bubbles, set_active_bubbles) = + signal(HashMap::<(Option, Option), ActiveBubble>::new()); + let realm_data = LocalResource::new(move || { let slug = slug.get(); async move { @@ -93,45 +106,31 @@ pub fn RealmPage() -> impl IntoView { } }); - // Fetch emotion availability and avatar render data for emote picker + // Fetch full avatar with paths for client-side emotion computation #[cfg(feature = "hydrate")] { - let slug_for_emotions = slug.clone(); + let slug_for_avatar = slug.clone(); Effect::new(move |_| { use gloo_net::http::Request; - let current_slug = slug_for_emotions.get(); + let current_slug = slug_for_avatar.get(); if current_slug.is_empty() { return; } - // Fetch emotion availability - let slug_clone = current_slug.clone(); + // Fetch full avatar with all paths resolved spawn_local(async move { - let response = Request::get(&format!("/api/realms/{}/avatar/emotions", slug_clone)) + let response = Request::get(&format!("/api/realms/{}/avatar", current_slug)) .send() .await; if let Ok(resp) = response { if resp.ok() { - if let Ok(avail) = resp.json::().await { + if let Ok(avatar) = resp.json::().await { + // Compute emotion availability client-side + let avail = avatar.compute_emotion_availability(); set_emotion_availability.set(Some(avail)); - } - } - } - }); - - // Fetch avatar render data for skin preview - let slug_clone2 = current_slug.clone(); - spawn_local(async move { - use chattyness_db::models::AvatarRenderData; - let response = Request::get(&format!("/api/realms/{}/avatar/current", slug_clone2)) - .send() - .await; - if let Ok(resp) = response { - if resp.ok() { - if let Ok(render_data) = resp.json::().await { - // Get skin layer position 4 (center) - set_skin_preview_path.set(render_data.skin_layer[4].clone()); + // Get skin layer position 4 (center) for preview + set_skin_preview_path.set(avatar.skin_layer[4].clone()); } } } @@ -145,11 +144,33 @@ pub fn RealmPage() -> impl IntoView { set_members.set(new_members); }); + // Chat message callback + #[cfg(feature = "hydrate")] + let on_chat_message = Callback::new(move |msg: ChatMessage| { + // Add to message log + message_log.update_value(|log| log.push(msg.clone())); + + // Update active bubbles + let key = (msg.user_id, msg.guest_session_id); + let expires_at = msg.timestamp + DEFAULT_BUBBLE_TIMEOUT_MS; + + set_active_bubbles.update(|bubbles| { + bubbles.insert( + key, + ActiveBubble { + message: msg, + expires_at, + }, + ); + }); + }); + #[cfg(feature = "hydrate")] let (_ws_state, ws_sender) = use_channel_websocket( slug, Signal::derive(move || channel_id.get()), on_members_update, + on_chat_message, ); // Set channel ID when scene loads (triggers WebSocket connection) @@ -163,6 +184,21 @@ pub fn RealmPage() -> impl IntoView { }); } + // Cleanup expired speech bubbles every 5 seconds + #[cfg(feature = "hydrate")] + { + use gloo_timers::callback::Interval; + + let cleanup_interval = Interval::new(5000, move || { + let now = js_sys::Date::now() as i64; + set_active_bubbles.update(|bubbles| { + bubbles.retain(|_, bubble| bubble.expires_at > now); + }); + }); + // Keep interval alive + std::mem::forget(cleanup_interval); + } + // Handle position update via WebSocket #[cfg(feature = "hydrate")] let on_move = Callback::new(move |(x, y): (f64, f64)| { @@ -211,7 +247,8 @@ pub fn RealmPage() -> impl IntoView { let key = ev.key(); // If chat is focused, let it handle all keys - if chat_focused.get() { + // Use get_untracked() since we're in a JS event handler, not a reactive context + if chat_focused.get_untracked() { *e_pressed_clone.borrow_mut() = false; return; } @@ -374,12 +411,14 @@ pub fn RealmPage() -> impl IntoView { let ws_for_chat = ws_sender_clone.clone(); #[cfg(not(feature = "hydrate"))] let ws_for_chat: StoredValue, LocalStorage> = StoredValue::new_local(None); + let active_bubbles_signal = Signal::derive(move || active_bubbles.get()); view! {