Compare commits

..

8 commits

Author SHA256 Message Date
98590f63e7 update stock props 2026-01-23 20:36:03 -06:00
fe40fd32ab clean up scene_viewer 2026-01-23 19:41:33 -06:00
2afe43547d cleanup avatar 2026-01-23 19:11:25 -06:00
8a37a7b2da minor cleanup with traits 2026-01-23 18:27:54 -06:00
73f9c95e37 feat: add delete form inventory 2026-01-23 17:42:41 -06:00
6e637a29cd feat: prop moving. 2026-01-23 17:11:12 -06:00
a2841c413d Fix prop renders
* Incorporate prop scaling
* Props now render to a canvas
2026-01-23 16:02:23 -06:00
af89394df1 fix: reocnnects 2026-01-23 13:48:56 -06:00
62 changed files with 4237 additions and 2215 deletions

View file

@ -9,6 +9,8 @@ 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;

View file

@ -6,7 +6,7 @@ use chattyness_db::{
models::{CreateServerAvatarRequest, ServerAvatar, ServerAvatarSummary, UpdateServerAvatarRequest},
queries::server_avatars,
};
use chattyness_error::AppError;
use chattyness_error::{AppError, OptionExt};
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
@ -87,7 +87,7 @@ pub async fn get_avatar(
) -> Result<Json<ServerAvatar>, AppError> {
let avatar = server_avatars::get_server_avatar_by_id(&pool, avatar_id)
.await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
.or_not_found("Avatar")?;
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?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
.or_not_found("Avatar")?;
// 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?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
.or_not_found("Avatar")?;
// Delete from database
server_avatars::delete_server_avatar(&mut *guard, avatar_id).await?;

View file

@ -0,0 +1,75 @@
//! 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<Uuid>,
) -> Result<Json<LooseProp>, 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<Uuid>,
Json(req): Json<UpdateLoosePropScaleRequest>,
) -> Result<Json<LooseProp>, 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<Uuid>,
) -> Result<Json<Vec<LooseProp>>, 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))
}

View file

@ -7,7 +7,7 @@ use chattyness_db::{
models::{CreateServerPropRequest, ServerProp, ServerPropSummary},
queries::props,
};
use chattyness_error::AppError;
use chattyness_error::{AppError, OptionExt};
use serde::Deserialize;
use serde::Serialize;
use sha2::{Digest, Sha256};
@ -218,7 +218,7 @@ pub async fn get_prop(
) -> Result<Json<ServerProp>, AppError> {
let prop = props::get_server_prop_by_id(&pool, prop_id)
.await?
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?;
.or_not_found("Prop")?;
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?
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?;
.or_not_found("Prop")?;
// Delete from database
props::delete_server_prop(&mut *guard, prop_id).await?;

View file

@ -11,7 +11,7 @@ use chattyness_db::{
},
queries::{owner as queries, realm_avatars},
};
use chattyness_error::AppError;
use chattyness_error::{AppError, OptionExt};
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?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
.or_not_found("Avatar")?;
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?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
.or_not_found("Avatar")?;
// 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?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
.or_not_found("Avatar")?;
// Delete from database
realm_avatars::delete_realm_avatar(&mut *guard, avatar_id).await?;

View file

@ -5,7 +5,7 @@ use axum::{
routing::{delete, get, post, put},
};
use super::{auth, avatars, config, dashboard, props, realms, scenes, spots, staff, users};
use super::{auth, avatars, config, dashboard, loose_props, props, realms, scenes, spots, staff, users};
use crate::app::AdminAppState;
/// Create the admin API router.
@ -85,6 +85,19 @@ pub fn admin_api_router() -> Router<AdminAppState> {
"/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",

View file

@ -8,7 +8,7 @@ use chattyness_db::{
models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest},
queries::{realms, scenes},
};
use chattyness_error::AppError;
use chattyness_error::{AppError, OptionExt};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::path::PathBuf;
@ -199,7 +199,7 @@ pub async fn get_scene(
) -> Result<Json<Scene>, AppError> {
let scene = scenes::get_scene_by_id(&pool, scene_id)
.await?
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
.or_not_found("Scene")?;
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?
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
.or_not_found("Scene")?;
// Handle clear background image
if req.clear_background_image {

View file

@ -8,7 +8,7 @@ use chattyness_db::{
models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest},
queries::spots,
};
use chattyness_error::AppError;
use chattyness_error::{AppError, OptionExt};
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
@ -31,7 +31,7 @@ pub async fn get_spot(
) -> Result<Json<Spot>, AppError> {
let spot = spots::get_spot_by_id(&pool, spot_id)
.await?
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
.or_not_found("Spot")?;
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?
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
.or_not_found("Spot")?;
if Some(new_slug.clone()) != existing.slug {
let available =

View file

@ -239,6 +239,8 @@ pub struct PropDetail {
pub default_layer: Option<String>,
/// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
pub default_position: Option<i16>,
/// 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,

View file

@ -5,6 +5,7 @@ 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]
@ -28,14 +29,7 @@ pub fn AvatarsNewPage() -> impl IntoView {
let new_name = event_target_value(&ev);
set_name.set(new_name.clone());
if slug_auto.get() {
let new_slug = new_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
set_slug.set(name_to_slug(&new_name));
}
};

View file

@ -95,6 +95,9 @@ fn PropDetailView(prop: PropDetail) -> impl IntoView {
None => "Not set".to_string(),
}}
</DetailItem>
<DetailItem label="Default Scale">
{format!("{}%", (prop.default_scale * 100.0) as i32)}
</DetailItem>
<DetailItem label="Status">
{if prop.is_active {
view! { <span class="status-badge status-active">"Active"</span> }.into_any()

View file

@ -5,6 +5,7 @@ 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]
@ -32,14 +33,7 @@ pub fn PropsNewPage() -> impl IntoView {
let new_name = event_target_value(&ev);
set_name.set(new_name.clone());
if slug_auto.get() {
let new_slug = new_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
set_slug.set(name_to_slug(&new_name));
}
};

View file

@ -6,6 +6,7 @@ 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;
@ -36,14 +37,7 @@ pub fn RealmAvatarsNewPage() -> impl IntoView {
let new_name = event_target_value(&ev);
set_name.set(new_name.clone());
if slug_auto.get() {
let new_slug = new_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_avatar_slug.set(new_slug);
set_avatar_slug.set(name_to_slug(&new_name));
}
};

View file

@ -5,6 +5,7 @@ use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::components::{Card, PageHeader};
use crate::utils::name_to_slug;
/// Realm new page component.
#[component]
@ -40,14 +41,7 @@ pub fn RealmNewPage() -> impl IntoView {
let new_name = event_target_value(&ev);
set_name.set(new_name.clone());
if slug_auto.get() {
let new_slug = new_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
set_slug.set(name_to_slug(&new_name));
}
};

View file

@ -6,6 +6,7 @@ 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;
@ -40,14 +41,7 @@ pub fn SceneNewPage() -> impl IntoView {
let new_name = event_target_value(&ev);
set_name.set(new_name.clone());
if slug_auto.get() {
let new_slug = new_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
set_slug.set(name_to_slug(&new_name));
}
};

View file

@ -1,5 +1,24 @@
//! 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::<String>()
.trim_matches('-')
.to_string()
}
/// Gets the API base path based on the current URL.
///
/// Returns `/api/admin` if the current path starts with `/admin`,

View file

@ -889,6 +889,8 @@ pub struct LooseProp {
pub realm_prop_id: Option<Uuid>,
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<Uuid>,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
@ -896,6 +898,12 @@ 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<Uuid>,
}
/// A server-wide prop (global library).
@ -915,6 +923,8 @@ pub struct ServerProp {
pub default_emotion: Option<EmotionState>,
/// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
pub default_position: Option<i16>,
/// 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,
@ -966,6 +976,9 @@ pub struct CreateServerPropRequest {
/// Whether prop appears in the public Server inventory tab.
#[serde(default)]
pub public: Option<bool>,
/// Default scale factor (0.1 - 10.0) applied when prop is dropped to canvas.
#[serde(default)]
pub default_scale: Option<f32>,
}
#[cfg(feature = "ssr")]
@ -999,6 +1012,14 @@ 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(())
}

View file

@ -1,5 +1,6 @@
//! Database query modules.
pub mod avatar_common;
pub mod avatars;
pub mod channel_members;
pub mod channels;

View file

@ -0,0 +1,657 @@
//! 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<Uuid>; LAYER_SIZE],
/// Clothes layer positions 0-8 (with user, worn items).
pub clothes: [Option<Uuid>; LAYER_SIZE],
/// Accessories layer positions 0-8 (in front of user, held/attached items).
pub accessories: [Option<Uuid>; LAYER_SIZE],
/// Emotion layers: 12 emotions × 9 positions each.
/// Index by EmotionState ordinal: neutral=0, happy=1, sad=2, etc.
pub emotions: [[Option<Uuid>; LAYER_SIZE]; EMOTION_COUNT],
}
impl AvatarSlots {
/// Get the emotion layer for a specific emotion state.
pub fn emotion_layer(&self, emotion: EmotionState) -> &[Option<Uuid>; LAYER_SIZE] {
&self.emotions[emotion.as_index()]
}
/// Collect all non-null prop UUIDs from all layers.
pub fn collect_all_prop_ids(&self) -> Vec<Uuid> {
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<Uuid> {
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<Self>
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<Self> {
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<String>, // Server avatars have slug, realm might not in some queries
pub name: String,
pub description: Option<String>,
// Skin layer paths
pub skin_0: Option<String>,
pub skin_1: Option<String>,
pub skin_2: Option<String>,
pub skin_3: Option<String>,
pub skin_4: Option<String>,
pub skin_5: Option<String>,
pub skin_6: Option<String>,
pub skin_7: Option<String>,
pub skin_8: Option<String>,
// Clothes layer paths
pub clothes_0: Option<String>,
pub clothes_1: Option<String>,
pub clothes_2: Option<String>,
pub clothes_3: Option<String>,
pub clothes_4: Option<String>,
pub clothes_5: Option<String>,
pub clothes_6: Option<String>,
pub clothes_7: Option<String>,
pub clothes_8: Option<String>,
// Accessories layer paths
pub accessories_0: Option<String>,
pub accessories_1: Option<String>,
pub accessories_2: Option<String>,
pub accessories_3: Option<String>,
pub accessories_4: Option<String>,
pub accessories_5: Option<String>,
pub accessories_6: Option<String>,
pub accessories_7: Option<String>,
pub accessories_8: Option<String>,
// Happy emotion layer paths (e1 - for store display)
pub emotion_0: Option<String>,
pub emotion_1: Option<String>,
pub emotion_2: Option<String>,
pub emotion_3: Option<String>,
pub emotion_4: Option<String>,
pub emotion_5: Option<String>,
pub emotion_6: Option<String>,
pub emotion_7: Option<String>,
pub emotion_8: Option<String>,
}
impl AvatarWithPathsRow {
/// Extract skin layer as array.
pub fn skin_layer(&self) -> [Option<String>; 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<String>; 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<String>; 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<String>; 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<HashMap<Uuid, String>, 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<Uuid, String>,
) -> AvatarRenderData {
let get_path = |id: Option<Uuid>| -> Option<String> {
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;

View file

@ -6,7 +6,7 @@ use sqlx::PgExecutor;
use uuid::Uuid;
use crate::models::{InventoryItem, LooseProp};
use chattyness_error::AppError;
use chattyness_error::{AppError, OptionExt};
/// Ensure an instance exists for a scene.
///
@ -46,11 +46,14 @@ 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
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
@ -81,6 +84,7 @@ pub async fn drop_prop_to_canvas<'e>(
) -> Result<LooseProp, AppError> {
// 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,
@ -91,6 +95,7 @@ pub async fn drop_prop_to_canvas<'e>(
Option<Uuid>,
Option<f32>,
Option<f32>,
Option<f32>,
Option<Uuid>,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::DateTime<chrono::Utc>>,
@ -99,9 +104,18 @@ pub async fn drop_prop_to_canvas<'e>(
)> = sqlx::query_as(
r#"
WITH item_info AS (
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
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
),
deleted_item AS (
DELETE FROM auth.inventory
@ -114,6 +128,7 @@ pub async fn drop_prop_to_canvas<'e>(
server_prop_id,
realm_prop_id,
position,
scale,
dropped_by,
expires_at
)
@ -122,6 +137,7 @@ 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
@ -132,6 +148,7 @@ 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
@ -146,6 +163,7 @@ 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,
@ -171,19 +189,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(),
@ -199,6 +217,7 @@ 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),
@ -213,11 +232,14 @@ 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,
})
}
_ => {
@ -308,11 +330,281 @@ pub async fn pick_up_loose_prop<'e>(
.bind(user_id)
.fetch_optional(executor)
.await?
.ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?;
.or_not_found("Loose prop (may have expired)")?;
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<LooseProp, AppError> {
// 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<Option<LooseProp>, 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<LooseProp, AppError> {
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<LooseProp, AppError> {
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<LooseProp, AppError> {
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.

View file

@ -48,6 +48,7 @@ pub async fn get_server_prop_by_id<'e>(
default_layer,
default_emotion,
default_position,
default_scale,
is_unique,
is_transferable,
is_portable,
@ -116,20 +117,23 @@ 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
$9,
$10, $11,
$12
)
RETURNING
id,
@ -142,6 +146,7 @@ pub async fn create_server_prop<'e>(
default_layer,
default_emotion,
default_position,
default_scale,
is_unique,
is_transferable,
is_portable,
@ -163,6 +168,7 @@ 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)
@ -207,20 +213,23 @@ 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
$9,
$10, $11,
$12
)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
@ -230,6 +239,7 @@ 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()
@ -244,6 +254,7 @@ pub async fn upsert_server_prop<'e>(
default_layer,
default_emotion,
default_position,
default_scale,
is_unique,
is_transferable,
is_portable,
@ -265,6 +276,7 @@ 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)

View file

@ -3,15 +3,22 @@
//! 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::models::{AvatarRenderData, EmotionState, RealmAvatar};
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 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>,
@ -72,7 +79,9 @@ pub async fn list_public_realm_avatars<'e>(
Ok(avatars)
}
use crate::models::RealmAvatarWithPaths;
// =============================================================================
// Avatar with Paths Queries
// =============================================================================
/// Row type for realm avatar with paths query.
#[derive(Debug, sqlx::FromRow)]
@ -110,7 +119,7 @@ struct RealmAvatarWithPathsRow {
accessories_6: Option<String>,
accessories_7: Option<String>,
accessories_8: Option<String>,
// Happy emotion layer paths (e1 - more inviting for store display)
// Happy emotion layer paths
emotion_0: Option<String>,
emotion_1: Option<String>,
emotion_2: Option<String>,
@ -153,236 +162,52 @@ impl From<RealmAvatarWithPathsRow> 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<Vec<RealmAvatarWithPaths>, AppError> {
let rows = sqlx::query_as::<_, RealmAvatarWithPathsRow>(
let join_clause = avatar_paths_join_clause("realm.props");
let query = format!(
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
"#,
)
.bind(realm_id)
.fetch_all(executor)
.await?;
avatar_paths_select_clause(),
join_clause
);
let rows = sqlx::query_as::<_, RealmAvatarWithPathsRow>(&query)
.bind(realm_id)
.fetch_all(executor)
.await?;
Ok(rows.into_iter().map(RealmAvatarWithPaths::from).collect())
}
/// Row type for prop asset lookup.
#[derive(Debug, sqlx::FromRow)]
struct PropAssetRow {
id: Uuid,
asset_path: String,
}
// =============================================================================
// Render Data Resolution
// =============================================================================
/// 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<AvatarRenderData, AppError> {
// Collect all non-null prop UUIDs
let mut prop_ids: Vec<Uuid> = Vec::new();
// 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<Uuid>; 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<Uuid, String> = 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<Uuid>| -> Option<String> {
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]),
],
})
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))
}
// =============================================================================
// Forced Avatar Management
// =============================================================================
/// Apply a forced realm avatar to a user.
pub async fn apply_forced_realm_avatar<'e>(
executor: impl PgExecutor<'e>,
@ -576,155 +401,65 @@ 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?;
@ -745,141 +480,51 @@ 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 *
@ -892,155 +537,65 @@ 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?;

View file

@ -4,7 +4,7 @@ use sqlx::PgExecutor;
use uuid::Uuid;
use crate::models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest};
use chattyness_error::AppError;
use chattyness_error::{AppError, OptionExt};
/// 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?
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
.or_not_found("Scene")?;
Ok(scene)
}

View file

@ -3,15 +3,22 @@
//! 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::models::{AvatarRenderData, EmotionState, ServerAvatar};
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 chattyness_error::AppError;
// =============================================================================
// Basic Queries
// =============================================================================
/// Get a server avatar by slug.
pub async fn get_server_avatar_by_slug<'e>(
executor: impl PgExecutor<'e>,
@ -68,9 +75,11 @@ pub async fn list_public_server_avatars<'e>(
Ok(avatars)
}
use crate::models::ServerAvatarWithPaths;
// =============================================================================
// Avatar with Paths Queries
// =============================================================================
/// Row type for server avatar with paths query.
/// Row type for server avatar with paths query (includes slug).
#[derive(Debug, sqlx::FromRow)]
struct ServerAvatarWithPathsRow {
id: Uuid,
@ -107,7 +116,7 @@ struct ServerAvatarWithPathsRow {
accessories_6: Option<String>,
accessories_7: Option<String>,
accessories_8: Option<String>,
// Happy emotion layer paths (e1 - more inviting for store display)
// Happy emotion layer paths
emotion_0: Option<String>,
emotion_1: Option<String>,
emotion_2: Option<String>,
@ -151,235 +160,52 @@ impl From<ServerAvatarWithPathsRow> 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<Vec<ServerAvatarWithPaths>, AppError> {
let rows = sqlx::query_as::<_, ServerAvatarWithPathsRow>(
let join_clause = avatar_paths_join_clause("server.props");
let query = format!(
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
"#,
)
.fetch_all(executor)
.await?;
avatar_paths_select_clause(),
join_clause
);
let rows = sqlx::query_as::<_, ServerAvatarWithPathsRow>(&query)
.fetch_all(executor)
.await?;
Ok(rows.into_iter().map(ServerAvatarWithPaths::from).collect())
}
/// Row type for prop asset lookup.
#[derive(Debug, sqlx::FromRow)]
struct PropAssetRow {
id: Uuid,
asset_path: String,
}
// =============================================================================
// Render Data Resolution
// =============================================================================
/// 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<AvatarRenderData, AppError> {
// Collect all non-null prop UUIDs
let mut prop_ids: Vec<Uuid> = Vec::new();
// 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<Uuid>; 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<Uuid, String> = 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<Uuid>| -> Option<String> {
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]),
],
})
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))
}
// =============================================================================
// Forced Avatar Management
// =============================================================================
/// Apply a forced server avatar to a user.
pub async fn apply_forced_server_avatar<'e>(
executor: impl PgExecutor<'e>,
@ -599,155 +425,65 @@ 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?;
@ -768,141 +504,51 @@ 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 *
@ -915,155 +561,65 @@ 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?;

View file

@ -4,7 +4,7 @@ use sqlx::PgExecutor;
use uuid::Uuid;
use crate::models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest};
use chattyness_error::AppError;
use chattyness_error::{AppError, OptionExt};
/// 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?
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
.or_not_found("Spot")?;
Ok(spot)
}

View file

@ -104,6 +104,42 @@ 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.
@ -221,6 +257,18 @@ 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.

View file

@ -1,6 +1,27 @@
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<T> {
/// Convert None to AppError::NotFound with a descriptive message.
fn or_not_found(self, entity: &str) -> Result<T, AppError>;
}
impl<T> OptionExt<T> for Option<T> {
fn or_not_found(self, entity: &str) -> Result<T, AppError> {
self.ok_or_else(|| AppError::NotFound(format!("{} not found", entity)))
}
}
/// Application error types for chattyness.
///
/// All errors derive From for automatic conversion where applicable.

View file

@ -11,7 +11,7 @@ use chattyness_db::{
models::{Scene, SceneSummary, Spot, SpotSummary},
queries::{realms, scenes, spots},
};
use chattyness_error::AppError;
use chattyness_error::{AppError, OptionExt};
/// Get the entry scene for a realm.
///
@ -86,7 +86,7 @@ pub async fn get_spot(
) -> Result<Json<Spot>, AppError> {
let spot = spots::get_spot_by_id(&pool, spot_id)
.await?
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
.or_not_found("Spot")?;
Ok(Json(spot))
}

View file

@ -19,7 +19,7 @@ use uuid::Uuid;
use chattyness_db::{
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, ForcedAvatarReason, User},
queries::{avatars, channel_members, loose_props, memberships, moderation, realm_avatars, realms, scenes, server_avatars, users},
queries::{avatars, channel_members, inventory, loose_props, memberships, moderation, realm_avatars, realms, scenes, server_avatars, users},
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
};
use chattyness_error::AppError;
@ -711,7 +711,57 @@ 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,
@ -1419,6 +1469,195 @@ 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) => {

View file

@ -2,6 +2,7 @@
pub mod avatar_canvas;
pub mod avatar_editor;
pub mod canvas_utils;
pub mod avatar_store;
pub mod avatar_thumbnail;
pub mod chat;
@ -17,6 +18,7 @@ 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;
@ -31,6 +33,7 @@ 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::*;
@ -45,6 +48,7 @@ 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::*;

View file

@ -9,6 +9,10 @@ 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
@ -802,15 +806,6 @@ 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.
///
@ -972,68 +967,6 @@ 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<Vec<u8>>
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")]

View file

@ -0,0 +1,77 @@
//! 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
}

View file

@ -8,7 +8,7 @@ use chattyness_db::models::{InventoryItem, PropAcquisitionInfo};
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage;
use super::modals::{GuestLockedOverlay, Modal};
use super::modals::{ConfirmModal, GuestLockedOverlay, Modal};
use super::tabs::{Tab, TabBar};
use super::ws_client::WsSender;
@ -45,6 +45,8 @@ pub fn InventoryPopup(
let (error, set_error) = signal(Option::<String>::None);
let (selected_item, set_selected_item) = signal(Option::<Uuid>::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::<PropAcquisitionInfo>::new());
@ -244,6 +246,33 @@ 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! {
<Modal
open=open
@ -277,6 +306,10 @@ pub fn InventoryPopup(
set_selected_item=set_selected_item
dropping=dropping
on_drop=Callback::new(handle_drop)
deleting=deleting
on_delete_request=Callback::new(move |(id, name)| {
set_delete_confirm_item.set(Some((id, name)));
})
/>
</Show>
@ -324,6 +357,25 @@ pub fn InventoryPopup(
<Show when=move || is_guest.get()>
<GuestLockedOverlay />
</Show>
// Delete confirmation modal
{move || {
delete_confirm_item.get().map(|(item_id, item_name)| {
view! {
<ConfirmModal
open=Signal::derive(|| true)
title="Delete Prop?"
message=format!("Permanently delete '{}'? This cannot be undone.", item_name)
confirm_text="Delete"
cancel_text="Cancel"
destructive=true
pending=Signal::derive(move || deleting.get())
on_confirm=Callback::new(move |_| handle_delete(item_id))
on_cancel=Callback::new(move |_| set_delete_confirm_item.set(None))
/>
}
})
}}
</div>
</Modal>
}
@ -339,6 +391,8 @@ fn MyInventoryTab(
set_selected_item: WriteSignal<Option<Uuid>>,
#[prop(into)] dropping: Signal<bool>,
#[prop(into)] on_drop: Callback<Uuid>,
#[prop(into)] deleting: Signal<bool>,
#[prop(into)] on_delete_request: Callback<(Uuid, String)>,
) -> impl IntoView {
view! {
// Loading state
@ -423,8 +477,11 @@ 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! {
<div class="mt-4 pt-4 border-t border-gray-700">
@ -452,10 +509,25 @@ fn MyInventoryTab(
}
}
disabled=is_dropping || !is_droppable
title=if is_droppable { "" } else { "Essential prop cannot be dropped" }
title=if is_droppable { "Drop prop to scene canvas" } else { "Essential prop cannot be dropped" }
>
{if is_dropping { "Dropping..." } else { "Drop" }}
</button>
// Delete button - only shown for droppable props
<Show when=move || is_droppable>
<button
type="button"
class="px-4 py-2 bg-red-800 hover:bg-red-900 text-white rounded-lg transition-colors disabled:opacity-50"
on:click={
let name = item_name.clone();
move |_| on_delete_request.run((item_id, name.clone()))
}
disabled=is_dropping || is_deleting
title="Permanently delete this prop"
>
"Delete"
</button>
</Show>
// Transfer button (disabled for now)
<Show when=move || item.is_transferable>
<button

View file

@ -0,0 +1,195 @@
//! Individual loose prop canvas component for per-prop rendering.
//!
//! Each loose prop gets its own canvas element positioned via CSS transforms.
//! This enables pixel-perfect hit detection using getImageData().
use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::LooseProp;
#[cfg(feature = "hydrate")]
pub use super::canvas_utils::hit_test_canvas;
#[cfg(feature = "hydrate")]
use super::canvas_utils::normalize_asset_path;
use super::settings::{BASE_AVATAR_SCALE, BASE_PROP_SCALE, BASE_PROP_SIZE};
/// Get a unique key for a loose prop (for Leptos For keying).
pub fn loose_prop_key(p: &LooseProp) -> Uuid {
p.id
}
/// Individual loose prop canvas component.
///
/// Renders a single prop with:
/// - CSS transform for position (GPU-accelerated, no redraw on move)
/// - Canvas for prop sprite (redraws only on appearance change)
/// - Pixel-perfect hit detection via getImageData()
#[component]
pub fn LoosePropCanvas(
/// The prop data (as a signal for reactive updates).
prop: Signal<LooseProp>,
/// X scale factor for coordinate conversion.
scale_x: Signal<f64>,
/// Y scale factor for coordinate conversion.
scale_y: Signal<f64>,
/// X offset for coordinate conversion.
offset_x: Signal<f64>,
/// Y offset for coordinate conversion.
offset_y: Signal<f64>,
/// Base prop size in screen pixels (already includes viewport scaling).
base_prop_size: Signal<f64>,
/// Z-index for stacking order.
z_index: i32,
) -> impl IntoView {
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
// Reactive style for CSS positioning (GPU-accelerated transforms)
let style = move || {
let p = prop.get();
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
let base_size = base_prop_size.get();
// Calculate rendered prop size
let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE;
let prop_size = base_size * prop_scale_ratio * p.scale as f64;
// Screen position (center of prop)
let screen_x = p.position_x * sx + ox;
let screen_y = p.position_y * sy + oy;
// Canvas positioned at top-left corner
let canvas_x = screen_x - prop_size / 2.0;
let canvas_y = screen_y - prop_size / 2.0;
// Add amber dashed border for locked props
let border_style = if p.is_locked {
"border: 2px dashed #f59e0b; box-sizing: border-box;"
} else {
""
};
format!(
"position: absolute; \
left: 0; top: 0; \
transform: translate({}px, {}px); \
z-index: {}; \
pointer-events: auto; \
width: {}px; \
height: {}px; {}",
canvas_x, canvas_y, z_index, prop_size, prop_size, border_style
)
};
#[cfg(feature = "hydrate")]
{
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
// Image cache for this prop
let image_cache: Rc<RefCell<Option<web_sys::HtmlImageElement>>> =
Rc::new(RefCell::new(None));
// Redraw trigger - incremented when image loads
let (redraw_trigger, set_redraw_trigger) = signal(0u32);
// Effect to draw the prop when canvas is ready or appearance changes
Effect::new(move |_| {
// Subscribe to redraw trigger
let _ = redraw_trigger.get();
let p = prop.get();
let base_size = base_prop_size.get();
let Some(canvas) = canvas_ref.get() else {
return;
};
// Calculate rendered prop size
let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE;
let prop_size = base_size * prop_scale_ratio * p.scale as f64;
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
// Set canvas resolution
canvas_el.set_width(prop_size as u32);
canvas_el.set_height(prop_size as u32);
let Ok(Some(ctx)) = canvas_el.get_context("2d") else {
return;
};
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
// Clear canvas
ctx.clear_rect(0.0, 0.0, prop_size, prop_size);
// Draw prop sprite if asset path available
if !p.prop_asset_path.is_empty() {
let normalized_path = normalize_asset_path(&p.prop_asset_path);
let mut cache = image_cache.borrow_mut();
if let Some(ref img) = *cache {
// Image in cache - draw if loaded
if img.complete() && img.natural_width() > 0 {
let _ = ctx.draw_image_with_html_image_element_and_dw_and_dh(
img, 0.0, 0.0, prop_size, prop_size,
);
}
} else {
// Not in cache - create and load
let img = web_sys::HtmlImageElement::new().unwrap();
let trigger = set_redraw_trigger;
let onload = Closure::once(Box::new(move || {
trigger.update(|v| *v += 1);
}) as Box<dyn FnOnce()>);
img.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
img.set_src(&normalized_path);
*cache = Some(img);
}
} else {
// Fallback: draw placeholder circle with prop name
ctx.begin_path();
let _ = ctx.arc(
prop_size / 2.0,
prop_size / 2.0,
prop_size / 2.0 - 2.0,
0.0,
std::f64::consts::PI * 2.0,
);
ctx.set_fill_style_str("#f59e0b");
ctx.fill();
ctx.set_stroke_style_str("#d97706");
ctx.set_line_width(2.0);
ctx.stroke();
// Draw prop name
let text_scale = prop_size / (BASE_PROP_SIZE * BASE_PROP_SCALE);
ctx.set_fill_style_str("#fff");
ctx.set_font(&format!("{}px sans-serif", 10.0 * text_scale));
ctx.set_text_align("center");
ctx.set_text_baseline("middle");
let _ = ctx.fill_text(&p.prop_name, prop_size / 2.0, prop_size / 2.0);
}
});
}
// Compute data-prop-id reactively
let data_prop_id = move || prop.get().id.to_string();
view! {
<canvas
node_ref=canvas_ref
style=style
data-prop-id=data_prop_id
/>
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,96 @@
//! Coordinate conversion utilities for scene viewer.
//!
//! Handles conversions between:
//! - Scene coordinates (native scene dimensions)
//! - Canvas coordinates (scaled/offset for display)
//! - Viewport/client coordinates (browser window)
/// Coordinate transform state for converting between scene and canvas coordinates.
#[derive(Clone, Copy, Debug, Default)]
pub struct CoordinateTransform {
pub scale_x: f64,
pub scale_y: f64,
pub offset_x: f64,
pub offset_y: f64,
}
impl CoordinateTransform {
/// Create a new transform with the given scale and offset values.
pub fn new(scale_x: f64, scale_y: f64, offset_x: f64, offset_y: f64) -> Self {
Self {
scale_x,
scale_y,
offset_x,
offset_y,
}
}
/// Convert canvas coordinates to scene coordinates.
pub fn canvas_to_scene(&self, canvas_x: f64, canvas_y: f64) -> (f64, f64) {
if self.scale_x > 0.0 && self.scale_y > 0.0 {
let scene_x = (canvas_x - self.offset_x) / self.scale_x;
let scene_y = (canvas_y - self.offset_y) / self.scale_y;
(scene_x, scene_y)
} else {
(0.0, 0.0)
}
}
/// Convert scene coordinates to canvas coordinates.
pub fn scene_to_canvas(&self, scene_x: f64, scene_y: f64) -> (f64, f64) {
let canvas_x = scene_x * self.scale_x + self.offset_x;
let canvas_y = scene_y * self.scale_y + self.offset_y;
(canvas_x, canvas_y)
}
/// Clamp scene coordinates to scene bounds.
pub fn clamp_to_scene(&self, x: f64, y: f64, scene_width: f64, scene_height: f64) -> (f64, f64) {
(x.max(0.0).min(scene_width), y.max(0.0).min(scene_height))
}
/// Check if the transform has valid (non-zero) scales.
pub fn is_valid(&self) -> bool {
self.scale_x > 0.0 && self.scale_y > 0.0
}
}
/// Calculate the aspect-ratio preserving scale and offset for fit mode.
///
/// Returns (draw_width, draw_height, offset_x, offset_y, scale_x, scale_y).
pub fn calculate_fit_transform(
display_width: f64,
display_height: f64,
scene_width: f64,
scene_height: f64,
) -> CoordinateTransform {
if display_width == 0.0 || display_height == 0.0 {
return CoordinateTransform::default();
}
let canvas_aspect = display_width / display_height;
let scene_aspect = scene_width / scene_height;
let (draw_width, draw_height, offset_x, offset_y) = if canvas_aspect > scene_aspect {
// Canvas is wider than scene - letterbox on sides
let h = display_height;
let w = h * scene_aspect;
let x = (display_width - w) / 2.0;
(w, h, x, 0.0)
} else {
// Canvas is taller than scene - letterbox on top/bottom
let w = display_width;
let h = w / scene_aspect;
let y = (display_height - h) / 2.0;
(w, h, 0.0, y)
};
let scale_x = draw_width / scene_width;
let scale_y = draw_height / scene_height;
CoordinateTransform::new(scale_x, scale_y, offset_x, offset_y)
}
/// Calculate transform for pan mode (native resolution * zoom).
pub fn calculate_pan_transform(zoom: f64) -> CoordinateTransform {
CoordinateTransform::new(zoom, zoom, 0.0, 0.0)
}

View file

@ -0,0 +1,392 @@
//! Effect functions for scene viewer background drawing and pan handling.
use leptos::prelude::*;
/// Set up viewport dimension tracking effect.
///
/// Tracks the outer container size and updates the provided signal.
/// Also listens for window resize events.
#[cfg(feature = "hydrate")]
pub fn setup_viewport_tracking(
outer_container_ref: NodeRef<leptos::html::Div>,
is_pan_mode: Signal<bool>,
set_viewport_dimensions: WriteSignal<(f64, f64)>,
) {
use wasm_bindgen::{JsCast, closure::Closure};
Effect::new(move |_| {
// Track pan mode to re-run when it changes
let _ = is_pan_mode.get();
let Some(container) = outer_container_ref.get() else {
return;
};
let container_el: web_sys::HtmlElement = container.into();
// Measure and update dimensions
let measure_container = {
let container_el = container_el.clone();
move || {
let width = container_el.client_width() as f64;
let height = container_el.client_height() as f64;
if width > 0.0 && height > 0.0 {
set_viewport_dimensions.set((width, height));
}
}
};
// Measure immediately
measure_container();
// Also measure on window resize
let resize_handler = Closure::wrap(Box::new({
let container_el = container_el.clone();
move |_: web_sys::Event| {
let width = container_el.client_width() as f64;
let height = container_el.client_height() as f64;
if width > 0.0 && height > 0.0 {
set_viewport_dimensions.set((width, height));
}
}
}) as Box<dyn Fn(web_sys::Event)>);
let window = web_sys::window().unwrap();
let _ = window.add_event_listener_with_callback(
"resize",
resize_handler.as_ref().unchecked_ref(),
);
// Keep the closure alive
resize_handler.forget();
});
}
/// Set up middle mouse button drag-to-pan effect.
#[cfg(feature = "hydrate")]
pub fn setup_middle_mouse_pan(
outer_container_ref: NodeRef<leptos::html::Div>,
is_pan_mode: Signal<bool>,
) {
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::{JsCast, closure::Closure};
Effect::new(move |_| {
let pan_mode_enabled = is_pan_mode.get();
let Some(container) = outer_container_ref.get() else {
return;
};
let container_el: web_sys::HtmlElement = container.into();
if !pan_mode_enabled {
// Reset cursor when not in pan mode
let _ = container_el.style().set_property("cursor", "");
return;
}
let is_dragging = Rc::new(Cell::new(false));
let last_x = Rc::new(Cell::new(0i32));
let last_y = Rc::new(Cell::new(0i32));
let container_for_move = container_el.clone();
let is_dragging_move = is_dragging.clone();
let last_x_move = last_x.clone();
let last_y_move = last_y.clone();
let container_for_down = container_el.clone();
let is_dragging_down = is_dragging.clone();
let last_x_down = last_x.clone();
let last_y_down = last_y.clone();
// Middle mouse down - start drag
let onmousedown =
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
if ev.button() == 1 {
is_dragging_down.set(true);
last_x_down.set(ev.client_x());
last_y_down.set(ev.client_y());
let _ = container_for_down.style().set_property("cursor", "grabbing");
ev.prevent_default();
}
});
// Mouse move - drag scroll
let onmousemove =
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
if is_dragging_move.get() {
let dx = last_x_move.get() - ev.client_x();
let dy = last_y_move.get() - ev.client_y();
last_x_move.set(ev.client_x());
last_y_move.set(ev.client_y());
container_for_move.set_scroll_left(container_for_move.scroll_left() + dx);
container_for_move.set_scroll_top(container_for_move.scroll_top() + dy);
}
});
let container_for_up = container_el.clone();
let is_dragging_up = is_dragging.clone();
// Mouse up - stop drag
let onmouseup =
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
if is_dragging_up.get() {
is_dragging_up.set(false);
let _ = container_for_up.style().set_property("cursor", "");
}
});
// Add event listeners
let _ = container_el.add_event_listener_with_callback(
"mousedown",
onmousedown.as_ref().unchecked_ref(),
);
let _ = container_el.add_event_listener_with_callback(
"mousemove",
onmousemove.as_ref().unchecked_ref(),
);
let _ = container_el
.add_event_listener_with_callback("mouseup", onmouseup.as_ref().unchecked_ref());
// Also listen for mouseup on window
if let Some(window) = web_sys::window() {
let is_dragging_window = is_dragging.clone();
let container_for_window = container_el.clone();
let onmouseup_window =
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |_ev: web_sys::MouseEvent| {
if is_dragging_window.get() {
is_dragging_window.set(false);
let _ = container_for_window.style().set_property("cursor", "");
}
});
let _ = window.add_event_listener_with_callback(
"mouseup",
onmouseup_window.as_ref().unchecked_ref(),
);
onmouseup_window.forget();
}
// Prevent context menu on middle click
let oncontextmenu =
Closure::<dyn Fn(web_sys::MouseEvent)>::new(move |ev: web_sys::MouseEvent| {
if ev.button() == 1 {
ev.prevent_default();
}
});
let _ = container_el.add_event_listener_with_callback(
"auxclick",
oncontextmenu.as_ref().unchecked_ref(),
);
// Keep closures alive
onmousedown.forget();
onmousemove.forget();
onmouseup.forget();
oncontextmenu.forget();
});
}
/// Set up wheel zoom effect for pan mode.
#[cfg(feature = "hydrate")]
pub fn setup_wheel_zoom(
outer_container_ref: NodeRef<leptos::html::Div>,
is_pan_mode: Signal<bool>,
on_zoom_change: Option<Callback<f64>>,
) {
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::{JsCast, closure::Closure};
let wheel_closure: Rc<RefCell<Option<Closure<dyn Fn(web_sys::WheelEvent)>>>> =
Rc::new(RefCell::new(None));
let wheel_closure_clone = wheel_closure.clone();
Effect::new(move |_| {
let pan_mode = is_pan_mode.get();
if let Some(container) = outer_container_ref.get() {
let element: &web_sys::Element = &container;
// Remove existing listener if any
if let Some(closure) = wheel_closure_clone.borrow().as_ref() {
let _ = element.remove_event_listener_with_callback(
"wheel",
closure.as_ref().unchecked_ref(),
);
}
if pan_mode {
// Add non-passive wheel listener for zoom
let closure = Closure::new(move |ev: web_sys::WheelEvent| {
if !ev.ctrl_key() {
if let Some(zoom_callback) = on_zoom_change {
let delta_y = ev.delta_y();
let zoom_delta = if delta_y < 0.0 { 0.1 } else { -0.1 };
zoom_callback.run(zoom_delta);
ev.prevent_default();
}
}
});
let options = web_sys::AddEventListenerOptions::new();
options.set_passive(false);
let _ = element.add_event_listener_with_callback_and_add_event_listener_options(
"wheel",
closure.as_ref().unchecked_ref(),
&options,
);
*wheel_closure_clone.borrow_mut() = Some(closure);
} else {
*wheel_closure_clone.borrow_mut() = None;
}
}
});
}
/// Draw background to canvas (handles both pan and fit modes).
#[cfg(feature = "hydrate")]
pub fn draw_background(
canvas_el: &web_sys::HtmlCanvasElement,
bg_color: &str,
image_path: &str,
has_background_image: bool,
scene_width: f64,
scene_height: f64,
is_pan_mode: bool,
zoom: f64,
set_scale_x: WriteSignal<f64>,
set_scale_y: WriteSignal<f64>,
set_offset_x: WriteSignal<f64>,
set_offset_y: WriteSignal<f64>,
set_scales_ready: WriteSignal<bool>,
) {
use wasm_bindgen::{JsCast, closure::Closure};
let canvas_el = canvas_el.clone();
let bg_color = bg_color.to_string();
let image_path = image_path.to_string();
let draw_bg = Closure::once(Box::new(move || {
if is_pan_mode {
// Pan mode: canvas at native resolution * zoom
let canvas_width = (scene_width * zoom) as u32;
let canvas_height = (scene_height * zoom) as u32;
canvas_el.set_width(canvas_width);
canvas_el.set_height(canvas_height);
set_scale_x.set(zoom);
set_scale_y.set(zoom);
set_offset_x.set(0.0);
set_offset_y.set(0.0);
set_scales_ready.set(true);
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
let ctx: web_sys::CanvasRenderingContext2d =
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
ctx.set_fill_style_str(&bg_color);
ctx.fill_rect(0.0, 0.0, canvas_width as f64, canvas_height as f64);
if has_background_image && !image_path.is_empty() {
let img = web_sys::HtmlImageElement::new().unwrap();
let img_clone = img.clone();
let ctx_clone = ctx.clone();
let onload = Closure::once(Box::new(move || {
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
&img_clone,
0.0,
0.0,
canvas_width as f64,
canvas_height as f64,
);
}) as Box<dyn FnOnce()>);
img.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
img.set_src(&image_path);
}
}
} else {
// Fit mode: scale to viewport with letterboxing
let display_width = canvas_el.client_width() as u32;
let display_height = canvas_el.client_height() as u32;
if display_width == 0 || display_height == 0 {
return;
}
canvas_el.set_width(display_width);
canvas_el.set_height(display_height);
let canvas_aspect = display_width as f64 / display_height as f64;
let scene_aspect = scene_width / scene_height;
let (draw_width, draw_height, draw_x, draw_y) = if canvas_aspect > scene_aspect {
let h = display_height as f64;
let w = h * scene_aspect;
let x = (display_width as f64 - w) / 2.0;
(w, h, x, 0.0)
} else {
let w = display_width as f64;
let h = w / scene_aspect;
let y = (display_height as f64 - h) / 2.0;
(w, h, 0.0, y)
};
let sx = draw_width / scene_width;
let sy = draw_height / scene_height;
set_scale_x.set(sx);
set_scale_y.set(sy);
set_offset_x.set(draw_x);
set_offset_y.set(draw_y);
set_scales_ready.set(true);
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
let ctx: web_sys::CanvasRenderingContext2d =
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
// Fill letterbox area with black
ctx.set_fill_style_str("#000");
ctx.fill_rect(0.0, 0.0, display_width as f64, display_height as f64);
// Fill scene area with background color
ctx.set_fill_style_str(&bg_color);
ctx.fill_rect(draw_x, draw_y, draw_width, draw_height);
if has_background_image && !image_path.is_empty() {
let img = web_sys::HtmlImageElement::new().unwrap();
let img_clone = img.clone();
let ctx_clone = ctx.clone();
let onload = Closure::once(Box::new(move || {
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
&img_clone,
draw_x,
draw_y,
draw_width,
draw_height,
);
}) as Box<dyn FnOnce()>);
img.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
img.set_src(&image_path);
}
}
}
}) as Box<dyn FnOnce()>);
// Use setTimeout with small delay to ensure canvas is in DOM
let window = web_sys::window().unwrap();
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
draw_bg.as_ref().unchecked_ref(),
100,
);
draw_bg.forget();
}

View file

@ -0,0 +1,306 @@
//! Overlay components for prop editing in scene viewer.
//!
//! Contains ScaleOverlay and MoveOverlay components used when
//! moderators edit prop scale or position.
use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::LooseProp;
use super::super::settings::{BASE_AVATAR_SCALE, BASE_PROP_SCALE};
#[cfg(feature = "hydrate")]
use super::super::canvas_utils::normalize_asset_path;
/// Overlay shown when editing a prop's scale.
///
/// Allows dragging from the prop center to adjust scale.
#[component]
pub fn ScaleOverlay(
#[prop(into)] active: Signal<bool>,
#[prop(into)] prop_id: Signal<Option<Uuid>>,
#[prop(into)] preview_scale: RwSignal<f32>,
#[prop(into)] prop_center: Signal<(f64, f64)>,
#[prop(into)] loose_props: Signal<Vec<LooseProp>>,
#[prop(into)] prop_size: Signal<f64>,
#[prop(optional)] on_apply: Option<Callback<(Uuid, f32)>>,
#[prop(optional)] on_cancel: Option<Callback<()>>,
) -> impl IntoView {
let (_center_x, _center_y) = prop_center.get_untracked();
view! {
<Show when=move || active.get()>
{move || {
let current_prop_id = prop_id.get();
let current_preview_scale = preview_scale.get();
let (center_x, center_y) = prop_center.get();
// Find the prop to get its dimensions
let prop_data = current_prop_id.and_then(|id| {
loose_props.get().iter().find(|p| p.id == id).cloned()
});
view! {
<div
class="fixed inset-0 z-50 cursor-crosshair"
style="background: rgba(0,0,0,0.3);"
on:mousemove=move |ev| {
#[cfg(feature = "hydrate")]
{
use wasm_bindgen::JsCast;
let ev: web_sys::MouseEvent = ev.dyn_into().unwrap();
let mouse_x = ev.client_x() as f64;
let mouse_y = ev.client_y() as f64;
let (cx, cy) = prop_center.get();
let dx = mouse_x - cx;
let dy = mouse_y - cy;
let distance = (dx * dx + dy * dy).sqrt();
// Scale formula: distance / 40 gives 1x at 40px
let new_scale = (distance / 40.0).clamp(0.1, 10.0) as f32;
preview_scale.set(new_scale);
}
#[cfg(not(feature = "hydrate"))]
let _ = ev;
}
on:mouseup=move |ev| {
#[cfg(feature = "hydrate")]
{
if let (Some(pid), Some(ref callback)) = (prop_id.get(), on_apply.as_ref()) {
let final_scale = preview_scale.get();
callback.run((pid, final_scale));
}
if let Some(ref callback) = on_cancel {
callback.run(());
}
}
#[cfg(not(feature = "hydrate"))]
let _ = ev;
}
on:keydown=move |ev| {
#[cfg(feature = "hydrate")]
{
use wasm_bindgen::JsCast;
let ev: web_sys::KeyboardEvent = ev.dyn_into().unwrap();
if ev.key() == "Escape" {
ev.prevent_default();
if let Some(ref callback) = on_cancel {
callback.run(());
}
}
}
#[cfg(not(feature = "hydrate"))]
let _ = ev;
}
tabindex="0"
>
// Visual feedback: dashed border around prop
{move || {
if let Some(ref _prop) = prop_data {
let base_size = prop_size.get();
let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE;
let preview_prop_size = base_size * prop_scale_ratio * current_preview_scale as f64;
let half_size = preview_prop_size / 2.0;
view! {
<div
class="absolute pointer-events-none"
style=format!(
"left: {}px; top: {}px; width: {}px; height: {}px; \
border: 2px dashed #fbbf24; \
transform: translate(-50%, -50%); \
box-sizing: border-box;",
center_x, center_y, preview_prop_size, preview_prop_size
)
/>
// Scale indicator
<div
class="absolute bg-gray-900/90 text-yellow-400 px-2 py-1 rounded text-sm font-mono pointer-events-none"
style=format!(
"left: {}px; top: {}px; transform: translate(-50%, 8px);",
center_x, center_y + half_size
)
>
{format!("{:.2}x", current_preview_scale)}
</div>
}.into_any()
} else {
().into_any()
}
}}
// Instructions
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-gray-900/90 text-white px-4 py-2 rounded text-sm">
"Drag to resize • Release to apply • Escape to cancel"
</div>
</div>
}
}}
</Show>
}
}
/// Overlay shown when moving a prop to a new position.
#[component]
pub fn MoveOverlay(
#[prop(into)] active: Signal<bool>,
#[prop(into)] prop_id: Signal<Option<Uuid>>,
#[prop(into)] preview_position: RwSignal<(f64, f64)>,
#[prop(into)] prop_scale: Signal<f32>,
#[prop(into)] loose_props: Signal<Vec<LooseProp>>,
#[prop(into)] prop_size: Signal<f64>,
#[prop(into)] scale_x: Signal<f64>,
#[prop(into)] scale_y: Signal<f64>,
#[prop(into)] offset_x: Signal<f64>,
#[prop(into)] offset_y: Signal<f64>,
#[prop(optional)] on_apply: Option<Callback<(Uuid, f64, f64)>>,
#[prop(optional)] on_cancel: Option<Callback<()>>,
) -> impl IntoView {
view! {
<Show when=move || active.get()>
{move || {
let current_prop_id = prop_id.get();
let (_preview_x, _preview_y) = preview_position.get();
let current_prop_scale = prop_scale.get();
// Find the prop to get its asset path
let prop_data = current_prop_id.and_then(|id| {
loose_props.get().iter().find(|p| p.id == id).cloned()
});
// Calculate ghost size
let base_size = prop_size.get();
let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE;
let ghost_size = base_size * prop_scale_ratio * current_prop_scale as f64;
view! {
<div
class="fixed inset-0 z-50 cursor-crosshair"
style="background: rgba(0,0,0,0.3);"
on:mousemove=move |ev| {
#[cfg(feature = "hydrate")]
{
use wasm_bindgen::JsCast;
let ev: web_sys::MouseEvent = ev.dyn_into().unwrap();
let mouse_x = ev.client_x() as f64;
let mouse_y = ev.client_y() as f64;
// Get scene viewer's position
let document = web_sys::window().unwrap().document().unwrap();
if let Some(viewer) = document.query_selector(".scene-viewer-container").ok().flatten() {
let rect = viewer.get_bounding_client_rect();
let viewer_x = mouse_x - rect.left();
let viewer_y = mouse_y - rect.top();
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
if sx > 0.0 && sy > 0.0 {
let scene_x = (viewer_x - ox) / sx;
let scene_y = (viewer_y - oy) / sy;
preview_position.set((scene_x, scene_y));
}
}
}
#[cfg(not(feature = "hydrate"))]
let _ = ev;
}
on:click=move |ev| {
#[cfg(feature = "hydrate")]
{
if let (Some(pid), Some(ref callback)) = (prop_id.get(), on_apply.as_ref()) {
let (final_x, final_y) = preview_position.get();
callback.run((pid, final_x, final_y));
}
if let Some(ref callback) = on_cancel {
callback.run(());
}
}
#[cfg(not(feature = "hydrate"))]
let _ = ev;
}
on:keydown=move |ev| {
#[cfg(feature = "hydrate")]
{
use wasm_bindgen::JsCast;
let ev: web_sys::KeyboardEvent = ev.dyn_into().unwrap();
if ev.key() == "Escape" {
ev.prevent_default();
if let Some(ref callback) = on_cancel {
callback.run(());
}
}
}
#[cfg(not(feature = "hydrate"))]
let _ = ev;
}
tabindex="0"
>
// Ghost prop at cursor position
{move || {
#[cfg(feature = "hydrate")]
{
if let Some(ref prop) = prop_data {
let (preview_x, preview_y) = preview_position.get();
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
// Get scene viewer position in viewport
let document = web_sys::window().unwrap().document().unwrap();
let viewer_offset = document
.query_selector(".scene-viewer-container")
.ok()
.flatten()
.map(|v| {
let rect = v.get_bounding_client_rect();
(rect.left(), rect.top())
})
.unwrap_or((0.0, 0.0));
// Convert scene coords to viewport coords
let viewer_x = preview_x * sx + ox;
let viewer_y = preview_y * sy + oy;
let viewport_x = viewer_x + viewer_offset.0;
let viewport_y = viewer_y + viewer_offset.1;
let normalized_path = normalize_asset_path(&prop.prop_asset_path);
view! {
<div
class="absolute pointer-events-none"
style=format!(
"left: {}px; top: {}px; width: {}px; height: {}px; \
transform: translate(-50%, -50%); \
border: 2px dashed #10b981; \
background: rgba(16, 185, 129, 0.2); \
box-sizing: border-box;",
viewport_x, viewport_y, ghost_size, ghost_size
)
>
<img
src=normalized_path
class="w-full h-full object-contain opacity-50"
style="pointer-events: none;"
/>
</div>
}.into_any()
} else {
().into_any()
}
}
#[cfg(not(feature = "hydrate"))]
{
().into_any()
}
}}
// Instructions
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-gray-900/90 text-white px-4 py-2 rounded text-sm">
"Click to place • Escape to cancel"
</div>
</div>
}
}}
</Show>
}
}

View file

@ -8,8 +8,17 @@ use crate::utils::LocalStoragePersist;
pub const REFERENCE_WIDTH: f64 = 1920.0;
pub const REFERENCE_HEIGHT: f64 = 1080.0;
/// Base size for props and avatars in scene space.
pub const BASE_PROP_SIZE: f64 = 60.0;
/// Base size for props/avatars in scene coordinates.
/// SVG assets are 120x120 pixels - this is the native/full size.
pub const BASE_PROP_SIZE: f64 = 120.0;
/// Scale factor for avatar rendering relative to BASE_PROP_SIZE.
/// Avatars render at 50% (60px cells) to allow merit-based scaling up later.
pub const BASE_AVATAR_SCALE: f64 = 0.5;
/// Scale factor for dropped loose props relative to BASE_PROP_SIZE.
/// Props render at 75% (90px) at default scale=1.0.
pub const BASE_PROP_SCALE: f64 = 0.75;
/// Minimum zoom level (25%).
pub const ZOOM_MIN: f64 = 0.25;

View file

@ -139,6 +139,8 @@ pub enum WsEvent {
PropDropped(LooseProp),
/// A prop was picked up (by prop ID).
PropPickedUp(uuid::Uuid),
/// A prop was updated (scale changed).
PropRefresh(LooseProp),
/// A member started fading out (timeout disconnect).
MemberFading(FadingMember),
/// Welcome message received with current user info.
@ -196,7 +198,7 @@ pub fn use_channel_websocket(
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use wasm_bindgen::{JsCast, closure::Closure};
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
use web_sys::{CloseEvent, MessageEvent, WebSocket};
let (ws_state, set_ws_state) = signal(WsState::Disconnected);
@ -254,6 +256,9 @@ pub fn use_channel_websocket(
// Close with SCENE_CHANGE code so onclose handler knows this was intentional
let _ = old_ws.close_with_code_and_reason(close_codes::SCENE_CHANGE, "scene change");
}
// Reset the intentional close flag for the new connection.
// This ensures the new connection's handlers don't see a stale flag.
state.is_intentional_close = false;
}
let Some(ch_id) = ch_id else {
@ -376,17 +381,19 @@ pub fn use_channel_websocket(
onmessage.forget();
// onerror
// Note: WebSocket.onerror receives a generic Event, not ErrorEvent.
// The event has no useful error details - just indicates an error occurred.
let set_ws_state_err = set_ws_state;
let ws_state_for_err = ws_state;
let reconnect_trigger_for_error = reconnect_trigger;
let is_disposed_for_err = is_disposed_for_effect.clone();
let onerror = Closure::wrap(Box::new(move |e: ErrorEvent| {
let onerror = Closure::wrap(Box::new(move |_e: web_sys::Event| {
// Skip if component has been disposed
if is_disposed_for_err.load(Ordering::Relaxed) {
return;
}
#[cfg(debug_assertions)]
web_sys::console::error_1(&format!("[WS] Error: {:?}", e.message()).into());
web_sys::console::error_1(&"[WS] Error occurred".into());
// Check if we're in silent reconnection mode
let current_state = ws_state_for_err.get_untracked();
@ -420,7 +427,7 @@ pub fn use_channel_websocket(
} else {
set_ws_state_err.set(WsState::Error);
}
}) as Box<dyn FnMut(ErrorEvent)>);
}) as Box<dyn FnMut(web_sys::Event)>);
ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
onerror.forget();
@ -516,6 +523,7 @@ fn handle_server_message(
LoosePropsSync(Vec<LooseProp>),
PropDropped(LooseProp),
PropPickedUp(uuid::Uuid),
PropRefresh(LooseProp),
Error(WsError),
TeleportApproved(TeleportInfo),
Summoned(SummonInfo),
@ -654,6 +662,14 @@ fn handle_server_message(
// Treat expired props the same as picked up (remove from display)
PostAction::PropPickedUp(prop_id)
}
ServerMessage::PropDeleted { inventory_item_id: _ } => {
// Inventory deletion is handled optimistically in the UI
// No scene state change needed
PostAction::None
}
ServerMessage::PropRefresh { prop } => {
PostAction::PropRefresh(prop)
}
ServerMessage::AvatarUpdated { user_id, avatar } => {
// Find member and update their avatar layers
if let Some(m) = state.members
@ -770,6 +786,9 @@ fn handle_server_message(
PostAction::PropPickedUp(prop_id) => {
on_event.run(WsEvent::PropPickedUp(prop_id));
}
PostAction::PropRefresh(prop) => {
on_event.run(WsEvent::PropRefresh(prop));
}
PostAction::Error(err) => {
on_event.run(WsEvent::Error(err));
}

View file

@ -476,6 +476,14 @@ pub fn RealmPage() -> impl IntoView {
}
});
}
WsEvent::PropRefresh(prop) => {
// Update the prop in the loose_props list (replace existing or ignore if not found)
set_loose_props.update(|props| {
if let Some(existing) = props.iter_mut().find(|p| p.id == prop.id) {
*existing = prop;
}
});
}
}
});
@ -1202,6 +1210,40 @@ pub fn RealmPage() -> impl IntoView {
});
});
let is_moderator_signal = Signal::derive(move || is_moderator.get());
#[cfg(feature = "hydrate")]
let ws_for_prop_scale = ws_sender_clone.clone();
let on_prop_scale_update_cb = Callback::new(move |(prop_id, scale): (Uuid, f32)| {
#[cfg(feature = "hydrate")]
ws_for_prop_scale.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::UpdateProp { loose_prop_id: prop_id, scale });
}
});
});
#[cfg(feature = "hydrate")]
let ws_for_prop_move = ws_sender_clone.clone();
let on_prop_move_cb = Callback::new(move |(prop_id, x, y): (Uuid, f64, f64)| {
#[cfg(feature = "hydrate")]
ws_for_prop_move.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::MoveProp { loose_prop_id: prop_id, x, y });
}
});
});
#[cfg(feature = "hydrate")]
let ws_for_prop_lock = ws_sender_clone.clone();
let on_prop_lock_toggle_cb = Callback::new(move |(prop_id, lock): (Uuid, bool)| {
#[cfg(feature = "hydrate")]
ws_for_prop_lock.with_value(|sender| {
if let Some(send_fn) = sender {
if lock {
send_fn(ClientMessage::LockProp { loose_prop_id: prop_id });
} else {
send_fn(ClientMessage::UnlockProp { loose_prop_id: prop_id });
}
}
});
});
view! {
<div class="relative w-full">
<RealmSceneViewer
@ -1223,6 +1265,10 @@ pub fn RealmPage() -> impl IntoView {
current_user_id=Signal::derive(move || current_user_id.get())
is_guest=Signal::derive(move || is_guest.get())
on_whisper_request=on_whisper_request_cb
is_moderator=is_moderator_signal
on_prop_scale_update=on_prop_scale_update_cb
on_prop_move=on_prop_move_cb
on_prop_lock_toggle=on_prop_lock_toggle_cb
/>
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
<ChatInput

View file

@ -279,6 +279,9 @@ CREATE TABLE server.props (
default_emotion server.emotion_state,
default_position SMALLINT CHECK (default_position IS NULL OR default_position BETWEEN 0 AND 8),
-- Default scale factor for dropped props (10% - 1000%)
default_scale REAL NOT NULL DEFAULT 1.0 CHECK (default_scale >= 0.1 AND default_scale <= 10.0),
is_unique BOOLEAN NOT NULL DEFAULT false,
is_transferable BOOLEAN NOT NULL DEFAULT true,
is_portable BOOLEAN NOT NULL DEFAULT true,

View file

@ -194,6 +194,9 @@ CREATE TABLE realm.props (
default_emotion server.emotion_state,
default_position SMALLINT CHECK (default_position IS NULL OR default_position BETWEEN 0 AND 8),
-- Default scale factor for dropped props (10% - 1000%)
default_scale REAL NOT NULL DEFAULT 1.0 CHECK (default_scale >= 0.1 AND default_scale <= 10.0),
is_unique BOOLEAN NOT NULL DEFAULT false,
is_transferable BOOLEAN NOT NULL DEFAULT true,
is_droppable BOOLEAN NOT NULL DEFAULT true,

View file

@ -210,6 +210,9 @@ CREATE TABLE scene.loose_props (
-- Position in scene (PostGIS point, SRID 0)
position public.virtual_point NOT NULL,
-- Scale factor (10% - 1000%), inherited from prop definition at drop time
scale REAL NOT NULL DEFAULT 1.0 CHECK (scale >= 0.1 AND scale <= 10.0),
-- Who dropped it (NULL = spawned by system/script)
dropped_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View file

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<defs>
<radialGradient id="armGradient" cx="30%" cy="30%" r="70%">
<stop offset="0%" stop-color="#FFB347"/>
<stop offset="100%" stop-color="#FF8C00"/>
</radialGradient>
<filter id="armShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.2"/>
</filter>
</defs>
<!-- Left arm - stubby sausage shape reaching up and out -->
<path d="M 120 90
Q 100 85 80 60
Q 65 40 50 25
Q 40 15 30 20
Q 20 25 25 40
Q 30 55 50 70
Q 70 85 95 95
Q 110 100 120 95
Z"
fill="url(#armGradient)" filter="url(#armShadow)"/>
<!-- Arm highlight -->
<path d="M 90 70 Q 70 50 55 35" stroke="#FFCC80" stroke-width="8" fill="none" stroke-linecap="round" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 960 B

View file

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<defs>
<radialGradient id="armGradient" cx="70%" cy="30%" r="70%">
<stop offset="0%" stop-color="#FFB347"/>
<stop offset="100%" stop-color="#FF8C00"/>
</radialGradient>
<filter id="armShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.2"/>
</filter>
</defs>
<!-- Right arm - stubby sausage shape reaching up and out (mirrored) -->
<path d="M 0 90
Q 20 85 40 60
Q 55 40 70 25
Q 80 15 90 20
Q 100 25 95 40
Q 90 55 70 70
Q 50 85 25 95
Q 10 100 0 95
Z"
fill="url(#armGradient)" filter="url(#armShadow)"/>
<!-- Arm highlight -->
<path d="M 30 70 Q 50 50 65 35" stroke="#FFCC80" stroke-width="8" fill="none" stroke-linecap="round" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 967 B

View file

@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<defs>
<radialGradient id="handGradient" cx="40%" cy="40%" r="60%">
<stop offset="0%" stop-color="#FFB347"/>
<stop offset="100%" stop-color="#FF8C00"/>
</radialGradient>
<filter id="handShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.2"/>
</filter>
</defs>
<!-- Waving hand - palm open, fingers spread -->
<!-- Palm -->
<ellipse cx="70" cy="75" rx="28" ry="25" fill="url(#handGradient)" filter="url(#handShadow)"/>
<!-- Thumb -->
<ellipse cx="95" cy="90" rx="12" ry="10" fill="url(#handGradient)" filter="url(#handShadow)"/>
<!-- Fingers - spread out in wave -->
<ellipse cx="45" cy="50" rx="10" ry="20" fill="url(#handGradient)" filter="url(#handShadow)" transform="rotate(-30 45 50)"/>
<ellipse cx="62" cy="42" rx="9" ry="22" fill="url(#handGradient)" filter="url(#handShadow)" transform="rotate(-10 62 42)"/>
<ellipse cx="80" cy="40" rx="9" ry="22" fill="url(#handGradient)" filter="url(#handShadow)" transform="rotate(10 80 40)"/>
<ellipse cx="96" cy="48" rx="8" ry="18" fill="url(#handGradient)" filter="url(#handShadow)" transform="rotate(25 96 48)"/>
<!-- Palm highlight -->
<ellipse cx="65" cy="70" rx="12" ry="10" fill="#FFCC80" opacity="0.5"/>
<!-- Motion lines for waving -->
<path d="M 20 30 Q 15 35 20 40" stroke="#4cc9f0" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.6"/>
<path d="M 12 45 Q 7 50 12 55" stroke="#4cc9f0" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M 25 55 Q 20 60 25 65" stroke="#4cc9f0" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<defs>
<radialGradient id="handGradient" cx="60%" cy="40%" r="60%">
<stop offset="0%" stop-color="#FFB347"/>
<stop offset="100%" stop-color="#FF8C00"/>
</radialGradient>
<filter id="handShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.2"/>
</filter>
</defs>
<!-- Waving hand - palm open, fingers spread (mirrored) -->
<!-- Palm -->
<ellipse cx="50" cy="75" rx="28" ry="25" fill="url(#handGradient)" filter="url(#handShadow)"/>
<!-- Thumb -->
<ellipse cx="25" cy="90" rx="12" ry="10" fill="url(#handGradient)" filter="url(#handShadow)"/>
<!-- Fingers - spread out in wave -->
<ellipse cx="75" cy="50" rx="10" ry="20" fill="url(#handGradient)" filter="url(#handShadow)" transform="rotate(30 75 50)"/>
<ellipse cx="58" cy="42" rx="9" ry="22" fill="url(#handGradient)" filter="url(#handShadow)" transform="rotate(10 58 42)"/>
<ellipse cx="40" cy="40" rx="9" ry="22" fill="url(#handGradient)" filter="url(#handShadow)" transform="rotate(-10 40 40)"/>
<ellipse cx="24" cy="48" rx="8" ry="18" fill="url(#handGradient)" filter="url(#handShadow)" transform="rotate(-25 24 48)"/>
<!-- Palm highlight -->
<ellipse cx="55" cy="70" rx="12" ry="10" fill="#FFCC80" opacity="0.5"/>
<!-- Motion lines for waving -->
<path d="M 100 30 Q 105 35 100 40" stroke="#4cc9f0" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.6"/>
<path d="M 108 45 Q 113 50 108 55" stroke="#4cc9f0" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M 95 55 Q 100 60 95 65" stroke="#4cc9f0" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<defs>
<!-- Matching the face colors -->
<linearGradient id="neckGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#CC9900"/>
<stop offset="30%" stop-color="#FFCC00"/>
<stop offset="70%" stop-color="#FFCC00"/>
<stop offset="100%" stop-color="#CC9900"/>
</linearGradient>
</defs>
<!-- Neck - cylindrical shape connecting to torso below -->
<rect x="42" y="0" width="36" height="120" fill="url(#neckGradient)"/>
<!-- Subtle center highlight -->
<rect x="52" y="0" width="16" height="120" fill="#FFE566" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 687 B

View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<!-- Decorative sparkles/stars above the head -->
<!-- Main star -->
<polygon points="60,10 63,25 78,28 65,35 68,50 60,40 52,50 55,35 42,28 57,25"
fill="#FFD700" opacity="0.9"/>
<!-- Smaller stars -->
<polygon points="25,45 27,52 34,53 28,57 30,64 25,59 20,64 22,57 16,53 23,52"
fill="#4cc9f0" opacity="0.7"/>
<polygon points="95,40 97,47 104,48 98,52 100,59 95,54 90,59 92,52 86,48 93,47"
fill="#4cc9f0" opacity="0.7"/>
<!-- Tiny sparkle dots -->
<circle cx="40" cy="25" r="3" fill="#FFFFFF" opacity="0.8"/>
<circle cx="80" cy="20" r="2.5" fill="#FFFFFF" opacity="0.7"/>
<circle cx="15" cy="70" r="2" fill="#FFD700" opacity="0.6"/>
<circle cx="105" cy="75" r="2" fill="#FFD700" opacity="0.6"/>
<!-- Swirl decorations -->
<path d="M 30 85 Q 35 80 40 85 Q 45 90 50 85" stroke="#FF69B4" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M 70 90 Q 75 85 80 90 Q 85 95 90 90" stroke="#FF69B4" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<defs>
<!-- Matching face yellow tones -->
<radialGradient id="torsoGradient" cx="35%" cy="25%" r="70%">
<stop offset="0%" stop-color="#FFE566"/>
<stop offset="50%" stop-color="#FFCC00"/>
<stop offset="100%" stop-color="#CC9900"/>
</radialGradient>
<linearGradient id="neckGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#CC9900"/>
<stop offset="30%" stop-color="#FFCC00"/>
<stop offset="70%" stop-color="#FFCC00"/>
<stop offset="100%" stop-color="#CC9900"/>
</linearGradient>
<filter id="bodyShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.25"/>
</filter>
</defs>
<!-- Neck stub connecting from above -->
<rect x="42" y="0" width="36" height="20" fill="url(#neckGradient)"/>
<rect x="52" y="0" width="16" height="20" fill="#FFE566" opacity="0.3"/>
<!-- Main torso - round friendly blob -->
<ellipse cx="60" cy="65" rx="55" ry="50" fill="url(#torsoGradient)" filter="url(#bodyShadow)" stroke="#CC9900" stroke-width="1.5"/>
<!-- Belly highlight -->
<ellipse cx="45" cy="50" rx="22" ry="18" fill="#FFFFFF" opacity="0.25"/>
<!-- Belly button -->
<ellipse cx="60" cy="70" rx="5" ry="6" fill="#B8860B" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,238 @@
#!/bin/bash
# Create a stock avatar from uploaded props and set it as server default.
#
# Usage: ./stock/avatar/create-stock-avatar.sh [--force|-f] [HOST]
#
# Prerequisites:
# 1. Props must be uploaded first: ./stock/avatar/upload-stockavatars.sh
# 2. Dev server must be running: ./run-dev.sh -f
#
# This script:
# 1. Queries existing props by slug to get UUIDs
# 2. Creates a server avatar with all emotion slots populated
# 3. Sets the avatar as the server default for all gender/age combinations
set -e
# Parse arguments
FORCE=""
HOST="http://localhost:3001"
DB="chattyness"
for arg in "$@"; do
case "$arg" in
--force|-f)
FORCE="?force=true"
;;
http://*)
HOST="$arg"
;;
esac
done
echo "=========================================="
echo "Creating Stock Avatar"
echo "=========================================="
echo "Host: $HOST"
echo "Database: $DB"
echo ""
# Check if server is running
echo "Checking server health..."
health_response=$(curl -s -o /dev/null -w "%{http_code}" "$HOST/api/admin/health" 2>/dev/null || echo "000")
if [ "$health_response" != "200" ]; then
echo "ERROR: Server is not responding at $HOST (HTTP $health_response)"
echo "Make sure the server is running: ./run-dev.sh -f"
exit 1
fi
echo "Server is healthy!"
echo ""
# Query prop UUIDs by slug
echo "Querying prop UUIDs..."
get_prop_id() {
local slug="$1"
psql -d "$DB" -t -A -c "SELECT id FROM server.props WHERE slug = '$slug'" 2>/dev/null | tr -d '[:space:]'
}
# Get face prop (skin layer)
FACE_ID=$(get_prop_id "face")
if [ -z "$FACE_ID" ]; then
echo "ERROR: Face prop not found. Run upload-stockavatars.sh first."
exit 1
fi
echo " face: $FACE_ID"
# Get emotion props
NEUTRAL_ID=$(get_prop_id "neutral")
SMILE_ID=$(get_prop_id "smile") # This is "happy" emotion
SAD_ID=$(get_prop_id "sad")
ANGRY_ID=$(get_prop_id "angry")
SURPRISED_ID=$(get_prop_id "surprised")
THINKING_ID=$(get_prop_id "thinking")
LAUGHING_ID=$(get_prop_id "laughing")
CRYING_ID=$(get_prop_id "crying")
LOVE_ID=$(get_prop_id "love")
CONFUSED_ID=$(get_prop_id "confused")
SLEEPING_ID=$(get_prop_id "sleeping")
WINK_ID=$(get_prop_id "wink")
# Validate all emotion props exist
missing=""
[ -z "$NEUTRAL_ID" ] && missing="$missing neutral"
[ -z "$SMILE_ID" ] && missing="$missing smile"
[ -z "$SAD_ID" ] && missing="$missing sad"
[ -z "$ANGRY_ID" ] && missing="$missing angry"
[ -z "$SURPRISED_ID" ] && missing="$missing surprised"
[ -z "$THINKING_ID" ] && missing="$missing thinking"
[ -z "$LAUGHING_ID" ] && missing="$missing laughing"
[ -z "$CRYING_ID" ] && missing="$missing crying"
[ -z "$LOVE_ID" ] && missing="$missing love"
[ -z "$CONFUSED_ID" ] && missing="$missing confused"
[ -z "$SLEEPING_ID" ] && missing="$missing sleeping"
[ -z "$WINK_ID" ] && missing="$missing wink"
if [ -n "$missing" ]; then
echo "ERROR: Missing emotion props:$missing"
echo "Run upload-stockavatars.sh first."
exit 1
fi
echo " neutral: $NEUTRAL_ID"
echo " smile (happy): $SMILE_ID"
echo " sad: $SAD_ID"
echo " angry: $ANGRY_ID"
echo " surprised: $SURPRISED_ID"
echo " thinking: $THINKING_ID"
echo " laughing: $LAUGHING_ID"
echo " crying: $CRYING_ID"
echo " love: $LOVE_ID"
echo " confused: $CONFUSED_ID"
echo " sleeping: $SLEEPING_ID"
echo " wink: $WINK_ID"
echo ""
# Check if avatar already exists
existing_avatar=$(psql -d "$DB" -t -A -c "SELECT id FROM server.avatars WHERE slug = 'stock-avatar'" 2>/dev/null | tr -d '[:space:]')
if [ -n "$existing_avatar" ] && [ -z "$FORCE" ]; then
echo "Stock avatar already exists with ID: $existing_avatar"
echo "Use --force to recreate it."
AVATAR_ID="$existing_avatar"
else
# Create the avatar via API
echo "Creating stock avatar via API..."
# Build the JSON payload
avatar_json=$(cat <<EOF
{
"name": "Stock Avatar",
"slug": "stock-avatar",
"description": "Default stock avatar with all emotion faces",
"is_public": true,
"l_skin_4": "$FACE_ID",
"e_neutral_4": "$NEUTRAL_ID",
"e_happy_4": "$SMILE_ID",
"e_sad_4": "$SAD_ID",
"e_angry_4": "$ANGRY_ID",
"e_surprised_4": "$SURPRISED_ID",
"e_thinking_4": "$THINKING_ID",
"e_laughing_4": "$LAUGHING_ID",
"e_crying_4": "$CRYING_ID",
"e_love_4": "$LOVE_ID",
"e_confused_4": "$CONFUSED_ID",
"e_sleeping_4": "$SLEEPING_ID",
"e_wink_4": "$WINK_ID"
}
EOF
)
# Delete existing if force mode
if [ -n "$existing_avatar" ]; then
echo " Deleting existing avatar..."
curl -s -X DELETE "$HOST/api/admin/avatars/$existing_avatar" > /dev/null
fi
# Create the avatar
response=$(curl -s -w "\n%{http_code}" -X POST "$HOST/api/admin/avatars" \
-H "Content-Type: application/json" \
-d "$avatar_json")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
AVATAR_ID=$(echo "$body" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
echo " ✓ Created avatar: $AVATAR_ID"
else
echo " ✗ Failed to create avatar (HTTP $http_code): $body"
exit 1
fi
fi
echo ""
# Set as server default for all gender/age combinations
echo "Setting stock avatar as server defaults..."
psql -d "$DB" -c "
UPDATE server.config SET
default_avatar_neutral_child = '$AVATAR_ID',
default_avatar_neutral_adult = '$AVATAR_ID',
default_avatar_male_child = '$AVATAR_ID',
default_avatar_male_adult = '$AVATAR_ID',
default_avatar_female_child = '$AVATAR_ID',
default_avatar_female_adult = '$AVATAR_ID',
updated_at = now()
WHERE id = '00000000-0000-0000-0000-000000000001'
" > /dev/null
echo " ✓ Set all 6 default avatar columns"
echo ""
# Verify
echo "=========================================="
echo "Verification"
echo "=========================================="
# Check avatar slots
echo "Avatar emotion slots populated:"
psql -d "$DB" -t -c "
SELECT
CASE WHEN l_skin_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' body (l_skin_4)',
CASE WHEN e_neutral_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' neutral',
CASE WHEN e_happy_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' happy',
CASE WHEN e_sad_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' sad',
CASE WHEN e_angry_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' angry',
CASE WHEN e_surprised_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' surprised',
CASE WHEN e_thinking_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' thinking',
CASE WHEN e_laughing_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' laughing',
CASE WHEN e_crying_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' crying',
CASE WHEN e_love_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' love',
CASE WHEN e_confused_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' confused',
CASE WHEN e_sleeping_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' sleeping',
CASE WHEN e_wink_4 IS NOT NULL THEN '✓' ELSE '✗' END || ' wink'
FROM server.avatars WHERE slug = 'stock-avatar'
" | tr '|' '\n' | grep -v '^$' | sed 's/^ */ /'
echo ""
# Check server defaults
echo "Server config defaults:"
psql -d "$DB" -t -c "
SELECT
CASE WHEN default_avatar_neutral_adult IS NOT NULL THEN '✓' ELSE '✗' END || ' default_avatar_neutral_adult',
CASE WHEN default_avatar_neutral_child IS NOT NULL THEN '✓' ELSE '✗' END || ' default_avatar_neutral_child',
CASE WHEN default_avatar_male_adult IS NOT NULL THEN '✓' ELSE '✗' END || ' default_avatar_male_adult',
CASE WHEN default_avatar_male_child IS NOT NULL THEN '✓' ELSE '✗' END || ' default_avatar_male_child',
CASE WHEN default_avatar_female_adult IS NOT NULL THEN '✓' ELSE '✗' END || ' default_avatar_female_adult',
CASE WHEN default_avatar_female_child IS NOT NULL THEN '✓' ELSE '✗' END || ' default_avatar_female_child'
FROM server.config WHERE id = '00000000-0000-0000-0000-000000000001'
" | tr '|' '\n' | grep -v '^$' | sed 's/^ */ /'
echo ""
echo "=========================================="
echo "Stock avatar setup complete!"
echo "Avatar ID: $AVATAR_ID"
echo "=========================================="

View file

@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<g transform="scale(2.5)">
<style>
:root {
--face-primary: #FFCC00;
--face-highlight: #FFE566;
--face-shadow: #CC9900;
}
.face-primary { stop-color: var(--face-primary); }
.face-highlight { stop-color: var(--face-highlight); }
.face-shadow { stop-color: var(--face-shadow); }
.face-stroke { stroke: var(--face-shadow); }
.bevel-fill { fill: var(--face-primary); }
</style>
<defs>
<!-- Radial gradient for 3D sphere effect -->
<radialGradient id="faceGradient" cx="35%" cy="35%" r="65%">
<stop offset="0%" stop-color="#FFFFFF"/>
<stop offset="50%" class="face-highlight"/>
<stop offset="100%" class="face-primary"/>
</radialGradient>
<!-- Darker gradient for bottom edge (bevel effect) -->
<radialGradient id="shadowGradient" cx="50%" cy="0%" r="100%">
<stop offset="60%" class="face-primary"/>
<stop offset="100%" class="face-shadow"/>
</radialGradient>
<!-- Drop shadow filter -->
<filter id="dropShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Main face with gradient and shadow -->
<circle cx="24" cy="24" r="20" fill="url(#faceGradient)" class="face-stroke" stroke-width="1.5" filter="url(#dropShadow)"/>
<!-- Subtle bottom bevel overlay -->
<ellipse cx="24" cy="32" rx="18" ry="12" fill="url(#shadowGradient)" opacity="0.3"/>
<!-- Specular highlight (top-left light reflection) -->
<ellipse cx="16" cy="14" rx="6" ry="4" fill="#FFFFFF" opacity="0.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

282
stock/avatar/index.html Normal file
View file

@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Avatar Renderer</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #1a1a2e;
color: #eee;
margin: 0;
padding: 2rem;
min-height: 100vh;
}
h1 {
text-align: center;
margin-bottom: 2rem;
}
.controls {
display: flex;
justify-content: center;
gap: 2rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.control-group label {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
input[type="color"] {
width: 60px;
height: 40px;
border: none;
border-radius: 8px;
cursor: pointer;
}
.avatar-preview {
display: flex;
justify-content: center;
margin-bottom: 3rem;
}
.avatar-grid-3x3 {
display: grid;
grid-template-columns: repeat(3, 120px);
grid-template-rows: repeat(3, 120px);
gap: 0;
}
.avatar-grid-3x3 .cell {
width: 120px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-grid-3x3 .cell > img {
width: 100%;
height: 100%;
object-fit: contain;
}
.avatar-container {
position: relative;
width: 100%;
height: 100%;
}
.avatar-container img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.avatar-grid {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
justify-content: center;
}
.avatar-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: #16213e;
border-radius: 12px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.avatar-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.avatar-card.selected {
outline: 3px solid #4cc9f0;
}
.avatar-card .avatar-small {
position: relative;
width: 120px;
height: 120px;
}
.avatar-card .avatar-small img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.avatar-card span {
font-size: 0.875rem;
text-transform: capitalize;
}
</style>
</head>
<body>
<h1>Avatar Renderer</h1>
<div class="controls">
<div class="control-group">
<label for="primaryColor">Face Color</label>
<input type="color" id="primaryColor" value="#FFCC00">
</div>
<div class="control-group">
<label for="highlightColor">Highlight</label>
<input type="color" id="highlightColor" value="#FFE566">
</div>
<div class="control-group">
<label for="shadowColor">Shadow</label>
<input type="color" id="shadowColor" value="#CC9900">
</div>
</div>
<div class="avatar-preview">
<div class="avatar-grid-3x3">
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell center">
<div class="avatar-container" id="mainAvatar">
<img src="face.svg" alt="Face base" class="face-layer">
<img src="smile.svg" alt="Expression" class="expression-layer">
</div>
</div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell">
<img src="body-torso.svg" alt="Torso">
</div>
<div class="cell"></div>
</div>
</div>
<h2 style="text-align: center; margin-bottom: 1rem;">Expressions</h2>
<div class="avatar-grid" id="avatarGrid"></div>
<script>
const expressions = [
'smile',
'neutral',
'angry',
'sad',
'laughing',
'surprised',
'confused',
'love',
'wink',
'thinking',
'sleeping',
'crying'
];
const avatarGrid = document.getElementById('avatarGrid');
const mainAvatar = document.getElementById('mainAvatar');
const primaryColorInput = document.getElementById('primaryColor');
const highlightColorInput = document.getElementById('highlightColor');
const shadowColorInput = document.getElementById('shadowColor');
let currentExpression = 'smile';
// Create avatar cards
expressions.forEach(expression => {
const card = document.createElement('div');
card.className = 'avatar-card' + (expression === 'smile' ? ' selected' : '');
card.dataset.expression = expression;
card.innerHTML = `
<div class="avatar-small">
<img src="face.svg" alt="Face base" class="face-layer">
<img src="${expression}.svg" alt="${expression}" class="expression-layer">
</div>
<span>${expression}</span>
`;
card.addEventListener('click', () => selectExpression(expression));
avatarGrid.appendChild(card);
});
function selectExpression(expression) {
currentExpression = expression;
// Update main preview
mainAvatar.querySelector('.expression-layer').src = `${expression}.svg`;
// Update selected state
document.querySelectorAll('.avatar-card').forEach(card => {
card.classList.toggle('selected', card.dataset.expression === expression);
});
}
// Color manipulation
async function updateColors() {
const primary = primaryColorInput.value;
const highlight = highlightColorInput.value;
const shadow = shadowColorInput.value;
// Fetch and modify the face SVG
const response = await fetch('face.svg');
const svgText = await response.text();
// Replace the CSS variable defaults with our colors
const modifiedSvg = svgText
.replace(/--face-primary:\s*#[A-Fa-f0-9]+/g, `--face-primary: ${primary}`)
.replace(/--face-highlight:\s*#[A-Fa-f0-9]+/g, `--face-highlight: ${highlight}`)
.replace(/--face-shadow:\s*#[A-Fa-f0-9]+/g, `--face-shadow: ${shadow}`)
// Also update the hardcoded gradient colors in defs
.replace(/stop-color="#FFFFFF"/g, 'stop-color="#FFFFFF"') // Keep white
.replace(/<stop offset="50%" class="face-highlight"\/>/g, `<stop offset="50%" stop-color="${highlight}"/>`)
.replace(/<stop offset="100%" class="face-primary"\/>/g, `<stop offset="100%" stop-color="${primary}"/>`)
.replace(/<stop offset="60%" class="face-primary"\/>/g, `<stop offset="60%" stop-color="${primary}"/>`)
.replace(/<stop offset="100%" class="face-shadow"\/>/g, `<stop offset="100%" stop-color="${shadow}"/>`);
// Create blob URL for modified SVG
const blob = new Blob([modifiedSvg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
// Update all face layers
document.querySelectorAll('.face-layer').forEach(img => {
// Revoke old blob URL if exists
if (img.dataset.blobUrl) {
URL.revokeObjectURL(img.dataset.blobUrl);
}
img.src = url;
img.dataset.blobUrl = url;
});
}
primaryColorInput.addEventListener('input', updateColors);
highlightColorInput.addEventListener('input', updateColors);
shadowColorInput.addEventListener('input', updateColors);
</script>
</body>
</html>

View file

@ -429,6 +429,16 @@
<h3>Good Pol</h3>
<div class="prop-items" id="goodpol-props" role="group" aria-label="Good Pol props"></div>
</div>
<div class="prop-category">
<h3>Screens</h3>
<div class="prop-items" id="screen-props" role="group" aria-label="Screen props"></div>
</div>
<div class="prop-category">
<h3>Keyboards</h3>
<div class="prop-items" id="keyboard-props" role="group" aria-label="Keyboard props"></div>
</div>
</div>
</section>
@ -548,7 +558,9 @@
soda: ['cola', 'lemonlime', 'orange', 'grape', 'rootbeer'],
tea: ['iced', 'pot', 'cup', 'cup-empty', 'bag'],
misc: ['iou', 'signed-dollar', 'thankyou', 'yousuck'],
goodpol: ['cccp', 'china', 'palestine']
goodpol: ['cccp', 'china', 'palestine'],
screen: ['projector', 'projector-with-stand'],
keyboard: ['media']
};
// Flags
@ -638,6 +650,8 @@
const teaContainer = document.getElementById('tea-props');
const miscContainer = document.getElementById('misc-props');
const goodpolContainer = document.getElementById('goodpol-props');
const screenContainer = document.getElementById('screen-props');
const keyboardContainer = document.getElementById('keyboard-props');
for (const name of props.hookah) {
await loadPropPreview('hookah', name, hookahContainer);
@ -657,6 +671,12 @@
for (const name of props.goodpol) {
await loadPropPreview('goodpol', name, goodpolContainer);
}
for (const name of props.screen) {
await loadPropPreview('screen', name, screenContainer);
}
for (const name of props.keyboard) {
await loadPropPreview('keyboard', name, keyboardContainer);
}
// Select first prop by default
const firstCard = document.querySelector('#props-tab .prop-card');

View file

@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<defs>
<linearGradient id="keyboardBody" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#3a3a3a"/>
<stop offset="100%" stop-color="#2a2a2a"/>
</linearGradient>
<linearGradient id="keyTop" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#4a4a4a"/>
<stop offset="100%" stop-color="#3a3a3a"/>
</linearGradient>
<filter id="keyShadow" x="-10%" y="-10%" width="120%" height="130%">
<feDropShadow dx="0" dy="1" stdDeviation="0.5" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<!-- Keyboard body -->
<rect x="8" y="40" width="104" height="50" rx="4" fill="url(#keyboardBody)"/>
<rect x="8" y="40" width="104" height="50" rx="4" fill="none" stroke="#222" stroke-width="1"/>
<!-- Top row: Play, Pause, Stop -->
<!-- Play key -->
<rect x="14" y="46" width="22" height="16" rx="2" fill="url(#keyTop)" filter="url(#keyShadow)"/>
<polygon points="22,50 22,58 29,54" fill="#4CAF50"/>
<!-- Pause key -->
<rect x="40" y="46" width="22" height="16" rx="2" fill="url(#keyTop)" filter="url(#keyShadow)"/>
<rect x="47" y="50" width="3" height="8" fill="#FFC107"/>
<rect x="52" y="50" width="3" height="8" fill="#FFC107"/>
<!-- Stop key -->
<rect x="66" y="46" width="22" height="16" rx="2" fill="url(#keyTop)" filter="url(#keyShadow)"/>
<rect x="72" y="50" width="10" height="8" fill="#F44336"/>
<!-- Mute key -->
<rect x="92" y="46" width="14" height="16" rx="2" fill="url(#keyTop)" filter="url(#keyShadow)"/>
<!-- Speaker icon -->
<polygon points="95,52 97,52 100,49 100,59 97,56 95,56" fill="#fff"/>
<!-- X for mute -->
<line x1="101" y1="51" x2="104" y2="57" stroke="#F44336" stroke-width="1.5" stroke-linecap="round"/>
<line x1="104" y1="51" x2="101" y2="57" stroke="#F44336" stroke-width="1.5" stroke-linecap="round"/>
<!-- URL bar -->
<rect x="14" y="66" width="92" height="18" rx="2" fill="#fff" filter="url(#keyShadow)"/>
<rect x="14" y="66" width="92" height="18" rx="2" fill="none" stroke="#888" stroke-width="0.5"/>
<text x="18" y="78" font-family="monospace" font-size="7" fill="#4CAF50">https://</text>
<line x1="52" y1="69" x2="52" y2="81" stroke="#333" stroke-width="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<defs>
<linearGradient id="screenSurface" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#FFFFFF"/>
<stop offset="100%" stop-color="#F0F0F0"/>
</linearGradient>
<linearGradient id="caseGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#4A4A4A"/>
<stop offset="100%" stop-color="#2A2A2A"/>
</linearGradient>
<filter id="screenShadow" x="-5%" y="-5%" width="110%" height="110%">
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Mounting bracket / case at top -->
<rect x="10" y="8" width="100" height="8" rx="2" fill="url(#caseGrad)"/>
<!-- Screen surface - 16:9 aspect ratio (96x54) -->
<rect x="12" y="18" width="96" height="54" fill="url(#screenSurface)" filter="url(#screenShadow)"/>
<!-- Screen border/frame -->
<rect x="12" y="18" width="96" height="54" fill="none" stroke="#333" stroke-width="1.5"/>
<!-- Bottom weight bar -->
<rect x="12" y="70" width="96" height="4" rx="1" fill="#3A3A3A"/>
<!-- Pull tab -->
<rect x="54" y="74" width="12" height="6" rx="1" fill="#555"/>
<circle cx="60" cy="80" r="3" fill="#666"/>
<circle cx="60" cy="80" r="1.5" fill="#444"/>
<!-- Tripod stand -->
<rect x="58" y="84" width="4" height="20" fill="#333"/>
<!-- Tripod legs -->
<line x1="60" y1="104" x2="30" y2="115" stroke="#333" stroke-width="3" stroke-linecap="round"/>
<line x1="60" y1="104" x2="90" y2="115" stroke="#333" stroke-width="3" stroke-linecap="round"/>
<line x1="60" y1="104" x2="60" y2="116" stroke="#333" stroke-width="3" stroke-linecap="round"/>
<!-- Rubber feet -->
<circle cx="30" cy="115" r="2" fill="#222"/>
<circle cx="90" cy="115" r="2" fill="#222"/>
<circle cx="60" cy="116" r="2" fill="#222"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
<defs>
<linearGradient id="ustScreen" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#FAFAFA"/>
<stop offset="50%" stop-color="#FFFFFF"/>
<stop offset="100%" stop-color="#F5F5F5"/>
</linearGradient>
<filter id="ustShadow" x="-5%" y="-5%" width="110%" height="120%">
<feDropShadow dx="0" dy="3" stdDeviation="3" flood-color="#000000" flood-opacity="0.25"/>
</filter>
</defs>
<!-- Thin black frame - 16:9 ratio (106x60) centered -->
<rect x="7" y="30" width="106" height="60" rx="1" fill="#1a1a1a" filter="url(#ustShadow)"/>
<!-- Screen surface - 16:9 with thin bezel -->
<rect x="9" y="32" width="102" height="56" fill="url(#ustScreen)"/>
<!-- Subtle inner shadow on screen edges -->
<rect x="9" y="32" width="102" height="56" fill="none" stroke="#E0E0E0" stroke-width="0.5"/>
<!-- Frame edge highlight (top) -->
<line x1="8" y1="30" x2="112" y2="30" stroke="#333" stroke-width="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -88,6 +88,12 @@ get_tags() {
misc)
echo '["misc", "droppable"]'
;;
screen)
echo '["screen", "projector", "droppable"]'
;;
keyboard)
echo '["keyboard", "media", "droppable"]'
;;
*)
echo '["prop", "droppable"]'
;;