make emotions named instead, add drop prop

This commit is contained in:
Evan Carroll 2026-01-13 16:49:07 -06:00
parent 989e20757b
commit ea3b444d71
19 changed files with 1429 additions and 150 deletions

View file

@ -343,6 +343,45 @@ impl std::str::FromStr for EmotionState {
}
}
impl EmotionState {
/// Convert a keybinding index (0-11) to an emotion state.
pub fn from_index(i: u8) -> Option<Self> {
match i {
0 => Some(Self::Neutral),
1 => Some(Self::Happy),
2 => Some(Self::Sad),
3 => Some(Self::Angry),
4 => Some(Self::Surprised),
5 => Some(Self::Thinking),
6 => Some(Self::Laughing),
7 => Some(Self::Crying),
8 => Some(Self::Love),
9 => Some(Self::Confused),
10 => Some(Self::Sleeping),
11 => Some(Self::Wink),
_ => None,
}
}
/// Convert emotion state to its index (0-11).
pub fn to_index(&self) -> u8 {
match self {
Self::Neutral => 0,
Self::Happy => 1,
Self::Sad => 2,
Self::Angry => 3,
Self::Surprised => 4,
Self::Thinking => 5,
Self::Laughing => 6,
Self::Crying => 7,
Self::Love => 8,
Self::Confused => 9,
Self::Sleeping => 10,
Self::Wink => 11,
}
}
}
// =============================================================================
// User Models
// =============================================================================
@ -485,6 +524,8 @@ pub struct Scene {
pub is_hidden: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// Default public channel ID for this scene.
pub default_channel_id: Option<Uuid>,
}
/// Minimal scene info for listings.
@ -541,6 +582,67 @@ pub struct SpotSummary {
// Props Models
// =============================================================================
/// Origin source for a prop in inventory.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(feature = "ssr", sqlx(type_name = "prop_origin", rename_all = "snake_case"))]
#[serde(rename_all = "snake_case")]
pub enum PropOrigin {
#[default]
ServerLibrary,
RealmLibrary,
UserUpload,
}
impl std::fmt::Display for PropOrigin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PropOrigin::ServerLibrary => write!(f, "server_library"),
PropOrigin::RealmLibrary => write!(f, "realm_library"),
PropOrigin::UserUpload => write!(f, "user_upload"),
}
}
}
/// An inventory item (user-owned prop).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct InventoryItem {
pub id: Uuid,
pub prop_name: String,
pub prop_asset_path: String,
pub layer: Option<AvatarLayer>,
pub is_transferable: bool,
pub is_portable: bool,
pub origin: PropOrigin,
pub acquired_at: DateTime<Utc>,
}
/// Response for inventory list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryResponse {
pub items: Vec<InventoryItem>,
}
/// A prop dropped in a channel, available for pickup.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct LooseProp {
pub id: Uuid,
pub channel_id: Uuid,
pub server_prop_id: Option<Uuid>,
pub realm_prop_id: Option<Uuid>,
pub position_x: f64,
pub position_y: f64,
pub dropped_by: Option<Uuid>,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
/// Prop name (JOINed from source prop).
pub prop_name: String,
/// Asset path for rendering (JOINed from source prop).
pub prop_asset_path: String,
}
/// A server-wide prop (global library).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]

View file

@ -2,7 +2,10 @@
pub mod avatars;
pub mod channel_members;
pub mod channels;
pub mod guests;
pub mod inventory;
pub mod loose_props;
pub mod memberships;
pub mod owner;
pub mod props;

View file

@ -5,7 +5,7 @@ use std::collections::HashMap;
use sqlx::{postgres::PgConnection, PgExecutor, PgPool};
use uuid::Uuid;
use crate::models::{ActiveAvatar, AvatarWithPaths, EmotionAvailability};
use crate::models::{ActiveAvatar, AvatarWithPaths, EmotionAvailability, EmotionState};
use chattyness_error::AppError;
/// Get the active avatar for a user in a realm.
@ -35,29 +35,27 @@ pub async fn set_emotion<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
emotion: i16,
emotion: EmotionState,
) -> Result<[Option<String>; 9], AppError> {
if emotion < 0 || emotion > 11 {
return Err(AppError::Validation("Emotion must be 0-11".to_string()));
}
// Map emotion index to column prefix
// Map emotion to column prefix
let emotion_prefix = match emotion {
0 => "e_neutral",
1 => "e_happy",
2 => "e_sad",
3 => "e_angry",
4 => "e_surprised",
5 => "e_thinking",
6 => "e_laughing",
7 => "e_crying",
8 => "e_love",
9 => "e_confused",
10 => "e_sleeping",
11 => "e_wink",
_ => return Err(AppError::Validation("Emotion must be 0-11".to_string())),
EmotionState::Neutral => "e_neutral",
EmotionState::Happy => "e_happy",
EmotionState::Sad => "e_sad",
EmotionState::Angry => "e_angry",
EmotionState::Surprised => "e_surprised",
EmotionState::Thinking => "e_thinking",
EmotionState::Laughing => "e_laughing",
EmotionState::Crying => "e_crying",
EmotionState::Love => "e_love",
EmotionState::Confused => "e_confused",
EmotionState::Sleeping => "e_sleeping",
EmotionState::Wink => "e_wink",
};
// Get the numeric index for the database
let emotion_index = emotion.to_index() as i16;
// Build dynamic query for the specific emotion's 9 positions
let query = format!(
r#"
@ -86,7 +84,7 @@ pub async fn set_emotion<'e>(
let result = sqlx::query_as::<_, EmotionLayerRow>(&query)
.bind(user_id)
.bind(realm_id)
.bind(emotion)
.bind(emotion_index)
.fetch_optional(executor)
.await?;

View file

@ -0,0 +1,43 @@
//! Channel-related database queries.
use sqlx::PgExecutor;
use uuid::Uuid;
use chattyness_error::AppError;
/// Minimal channel info for WebSocket validation.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ChannelInfo {
/// Channel ID.
pub id: Uuid,
/// Scene ID this channel belongs to.
pub scene_id: Uuid,
/// Realm ID (from the scene).
pub realm_id: Uuid,
}
/// Get channel info by ID.
///
/// Returns the channel with its associated scene and realm IDs.
pub async fn get_channel_info<'e>(
executor: impl PgExecutor<'e>,
channel_id: Uuid,
) -> Result<Option<ChannelInfo>, AppError> {
let info = sqlx::query_as::<_, ChannelInfo>(
r#"
SELECT
c.id,
c.scene_id,
s.realm_id
FROM realm.channels c
JOIN realm.scenes s ON s.id = c.scene_id
WHERE c.id = $1
"#,
)
.bind(channel_id)
.fetch_optional(executor)
.await?;
Ok(info)
}

View file

@ -0,0 +1,62 @@
//! Inventory-related database queries.
use sqlx::PgExecutor;
use uuid::Uuid;
use crate::models::InventoryItem;
use chattyness_error::AppError;
/// List all inventory items for a user.
pub async fn list_user_inventory<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
) -> Result<Vec<InventoryItem>, AppError> {
let items = sqlx::query_as::<_, InventoryItem>(
r#"
SELECT
id,
prop_name,
prop_asset_path,
layer,
is_transferable,
is_portable,
origin,
acquired_at
FROM props.inventory
WHERE user_id = $1
ORDER BY acquired_at DESC
"#,
)
.bind(user_id)
.fetch_all(executor)
.await?;
Ok(items)
}
/// Drop a prop (remove from inventory).
pub async fn drop_inventory_item<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
item_id: Uuid,
) -> Result<(), AppError> {
let result = sqlx::query(
r#"
DELETE FROM props.inventory
WHERE id = $1 AND user_id = $2
RETURNING id
"#,
)
.bind(item_id)
.bind(user_id)
.fetch_optional(executor)
.await?;
if result.is_none() {
return Err(AppError::NotFound(
"Inventory item not found or not owned by user".to_string(),
));
}
Ok(())
}

View file

@ -0,0 +1,217 @@
//! Loose props database queries.
//!
//! Handles props dropped in channels that can be picked up by users.
use sqlx::PgExecutor;
use uuid::Uuid;
use crate::models::{InventoryItem, LooseProp};
use chattyness_error::AppError;
/// List all loose props in a channel (excluding expired).
pub async fn list_channel_loose_props<'e>(
executor: impl PgExecutor<'e>,
channel_id: Uuid,
) -> Result<Vec<LooseProp>, AppError> {
let props = sqlx::query_as::<_, LooseProp>(
r#"
SELECT
lp.id,
lp.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.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
FROM props.loose_props lp
LEFT JOIN server.props sp ON lp.server_prop_id = sp.id
LEFT JOIN props.realm_props rp ON lp.realm_prop_id = rp.id
WHERE lp.channel_id = $1
AND (lp.expires_at IS NULL OR lp.expires_at > now())
ORDER BY lp.created_at ASC
"#,
)
.bind(channel_id)
.fetch_all(executor)
.await?;
Ok(props)
}
/// Drop a prop from inventory to the canvas.
///
/// Deletes from inventory and inserts into loose_props with 30-minute expiry.
/// Returns the created loose prop.
pub async fn drop_prop_to_canvas<'e>(
executor: impl PgExecutor<'e>,
inventory_item_id: Uuid,
user_id: Uuid,
channel_id: Uuid,
position_x: f64,
position_y: f64,
) -> Result<LooseProp, AppError> {
// Use a CTE to delete from inventory and insert to loose_props in one query
let prop = sqlx::query_as::<_, LooseProp>(
r#"
WITH deleted_item AS (
DELETE FROM props.inventory
WHERE id = $1 AND user_id = $2
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path
),
inserted_prop AS (
INSERT INTO props.loose_props (
channel_id,
server_prop_id,
realm_prop_id,
position,
dropped_by,
expires_at
)
SELECT
$3,
di.server_prop_id,
di.realm_prop_id,
public.make_virtual_point($4::real, $5::real),
$2,
now() + interval '30 minutes'
FROM deleted_item di
RETURNING
id,
channel_id,
server_prop_id,
realm_prop_id,
ST_X(position) as position_x,
ST_Y(position) as position_y,
dropped_by,
expires_at,
created_at
)
SELECT
ip.id,
ip.channel_id,
ip.server_prop_id,
ip.realm_prop_id,
ip.position_x,
ip.position_y,
ip.dropped_by,
ip.expires_at,
ip.created_at,
di.prop_name,
di.prop_asset_path
FROM inserted_prop ip
CROSS JOIN deleted_item di
"#,
)
.bind(inventory_item_id)
.bind(user_id)
.bind(channel_id)
.bind(position_x as f32)
.bind(position_y as f32)
.fetch_optional(executor)
.await?
.ok_or_else(|| {
AppError::NotFound("Inventory item not found or not owned by user".to_string())
})?;
Ok(prop)
}
/// Pick up a loose prop (delete from loose_props, insert to inventory).
///
/// Returns the created inventory item.
pub async fn pick_up_loose_prop<'e>(
executor: impl PgExecutor<'e>,
loose_prop_id: Uuid,
user_id: Uuid,
) -> Result<InventoryItem, AppError> {
// Use a CTE to delete from loose_props and insert to inventory
let item = sqlx::query_as::<_, InventoryItem>(
r#"
WITH deleted_prop AS (
DELETE FROM props.loose_props
WHERE id = $1
AND (expires_at IS NULL OR expires_at > now())
RETURNING id, server_prop_id, realm_prop_id
),
source_info AS (
SELECT
COALESCE(sp.name, rp.name) as prop_name,
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
COALESCE(sp.default_layer, rp.default_layer) as layer,
COALESCE(sp.is_transferable, rp.is_transferable) as is_transferable,
COALESCE(sp.is_portable, true) as is_portable,
dp.server_prop_id,
dp.realm_prop_id
FROM deleted_prop dp
LEFT JOIN server.props sp ON dp.server_prop_id = sp.id
LEFT JOIN props.realm_props rp ON dp.realm_prop_id = rp.id
),
inserted_item AS (
INSERT INTO props.inventory (
user_id,
server_prop_id,
realm_prop_id,
prop_name,
prop_asset_path,
layer,
origin,
is_transferable,
is_portable,
provenance,
acquired_at
)
SELECT
$2,
si.server_prop_id,
si.realm_prop_id,
si.prop_name,
si.prop_asset_path,
si.layer,
'server_library'::props.prop_origin,
COALESCE(si.is_transferable, true),
COALESCE(si.is_portable, true),
'[]'::jsonb,
now()
FROM source_info si
RETURNING id, prop_name, prop_asset_path, layer, is_transferable, is_portable, acquired_at
)
SELECT
ii.id,
ii.prop_name,
ii.prop_asset_path,
ii.layer,
ii.is_transferable,
ii.is_portable,
'server_library'::props.prop_origin as origin,
ii.acquired_at
FROM inserted_item ii
"#,
)
.bind(loose_prop_id)
.bind(user_id)
.fetch_optional(executor)
.await?
.ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?;
Ok(item)
}
/// Delete expired loose props.
///
/// Returns the number of props deleted.
pub async fn cleanup_expired_props<'e>(executor: impl PgExecutor<'e>) -> Result<u64, AppError> {
let result = sqlx::query(
r#"
DELETE FROM props.loose_props
WHERE expires_at IS NOT NULL AND expires_at <= now()
"#,
)
.execute(executor)
.await?;
Ok(result.rows_affected())
}

View file

@ -42,24 +42,26 @@ pub async fn get_scene_by_id<'e>(
let scene = sqlx::query_as::<_, Scene>(
r#"
SELECT
id,
realm_id,
name,
slug,
description,
background_image_path,
background_color,
ST_AsText(bounds) as bounds_wkt,
dimension_mode,
ambient_audio_id,
ambient_volume,
sort_order,
is_entry_point,
is_hidden,
created_at,
updated_at
FROM realm.scenes
WHERE id = $1
s.id,
s.realm_id,
s.name,
s.slug,
s.description,
s.background_image_path,
s.background_color,
ST_AsText(s.bounds) as bounds_wkt,
s.dimension_mode,
s.ambient_audio_id,
s.ambient_volume,
s.sort_order,
s.is_entry_point,
s.is_hidden,
s.created_at,
s.updated_at,
c.id as default_channel_id
FROM realm.scenes s
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
WHERE s.id = $1
"#,
)
.bind(scene_id)
@ -78,24 +80,26 @@ pub async fn get_scene_by_slug<'e>(
let scene = sqlx::query_as::<_, Scene>(
r#"
SELECT
id,
realm_id,
name,
slug,
description,
background_image_path,
background_color,
ST_AsText(bounds) as bounds_wkt,
dimension_mode,
ambient_audio_id,
ambient_volume,
sort_order,
is_entry_point,
is_hidden,
created_at,
updated_at
FROM realm.scenes
WHERE realm_id = $1 AND slug = $2
s.id,
s.realm_id,
s.name,
s.slug,
s.description,
s.background_image_path,
s.background_color,
ST_AsText(s.bounds) as bounds_wkt,
s.dimension_mode,
s.ambient_audio_id,
s.ambient_volume,
s.sort_order,
s.is_entry_point,
s.is_hidden,
s.created_at,
s.updated_at,
c.id as default_channel_id
FROM realm.scenes s
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
WHERE s.realm_id = $1 AND s.slug = $2
"#,
)
.bind(realm_id)
@ -167,7 +171,8 @@ pub async fn create_scene<'e>(
is_entry_point,
is_hidden,
created_at,
updated_at
updated_at,
NULL::uuid as default_channel_id
"#,
)
.bind(realm_id)
@ -236,7 +241,8 @@ pub async fn create_scene_with_id<'e>(
is_entry_point,
is_hidden,
created_at,
updated_at
updated_at,
NULL::uuid as default_channel_id
"#,
)
.bind(scene_id)
@ -305,20 +311,27 @@ pub async fn update_scene<'e>(
// If no updates, just return the current scene
let query = if set_clauses.is_empty() {
r#"SELECT id, realm_id, name, slug, description, background_image_path,
background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode,
ambient_audio_id, ambient_volume, sort_order, is_entry_point,
is_hidden, created_at, updated_at
FROM realm.scenes WHERE id = $1"#.to_string()
r#"SELECT s.id, s.realm_id, s.name, s.slug, s.description, s.background_image_path,
s.background_color, ST_AsText(s.bounds) as bounds_wkt, s.dimension_mode,
s.ambient_audio_id, s.ambient_volume, s.sort_order, s.is_entry_point,
s.is_hidden, s.created_at, s.updated_at, c.id as default_channel_id
FROM realm.scenes s
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
WHERE s.id = $1"#.to_string()
} else {
set_clauses.push("updated_at = now()".to_string());
format!(
r#"UPDATE realm.scenes SET {}
WHERE id = $1
RETURNING id, realm_id, name, slug, description, background_image_path,
background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode,
ambient_audio_id, ambient_volume, sort_order, is_entry_point,
is_hidden, created_at, updated_at"#,
r#"WITH updated AS (
UPDATE realm.scenes SET {}
WHERE id = $1
RETURNING id, realm_id, name, slug, description, background_image_path,
background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode,
ambient_audio_id, ambient_volume, sort_order, is_entry_point,
is_hidden, created_at, updated_at
)
SELECT u.*, c.id as default_channel_id
FROM updated u
LEFT JOIN realm.channels c ON c.scene_id = u.id AND c.channel_type = 'public'"#,
set_clauses.join(", ")
)
};
@ -399,37 +412,42 @@ pub async fn get_next_sort_order<'e>(
/// 1. The scene specified by `default_scene_id` on the realm (if provided and exists)
/// 2. The first scene marked as `is_entry_point`
/// 3. The first scene by sort_order
///
/// Also includes the default public channel ID for the scene.
pub async fn get_entry_scene_for_realm<'e>(
executor: impl PgExecutor<'e>,
realm_id: Uuid,
default_scene_id: Option<Uuid>,
) -> Result<Option<Scene>, AppError> {
// Use a single query that handles the priority in SQL
// Joins with channels to get the default public channel for the scene
let scene = sqlx::query_as::<_, Scene>(
r#"
SELECT
id,
realm_id,
name,
slug,
description,
background_image_path,
background_color,
ST_AsText(bounds) as bounds_wkt,
dimension_mode,
ambient_audio_id,
ambient_volume,
sort_order,
is_entry_point,
is_hidden,
created_at,
updated_at
FROM realm.scenes
WHERE realm_id = $1 AND is_hidden = false
s.id,
s.realm_id,
s.name,
s.slug,
s.description,
s.background_image_path,
s.background_color,
ST_AsText(s.bounds) as bounds_wkt,
s.dimension_mode,
s.ambient_audio_id,
s.ambient_volume,
s.sort_order,
s.is_entry_point,
s.is_hidden,
s.created_at,
s.updated_at,
c.id as default_channel_id
FROM realm.scenes s
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
WHERE s.realm_id = $1 AND s.is_hidden = false
ORDER BY
CASE WHEN id = $2 THEN 0 ELSE 1 END,
is_entry_point DESC,
sort_order ASC
CASE WHEN s.id = $2 THEN 0 ELSE 1 END,
s.is_entry_point DESC,
s.sort_order ASC
LIMIT 1
"#,
)

View file

@ -5,7 +5,7 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::models::{ChannelMemberInfo, ChannelMemberWithAvatar};
use crate::models::{ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp};
/// Client-to-server WebSocket messages.
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -19,10 +19,10 @@ pub enum ClientMessage {
y: f64,
},
/// Update emotion (0-9).
/// Update emotion by name.
UpdateEmotion {
/// Emotion slot (0-9, keyboard: e0-e9).
emotion: u8,
/// Emotion name (e.g., "happy", "sad", "neutral").
emotion: String,
},
/// Ping to keep connection alive.
@ -33,6 +33,18 @@ pub enum ClientMessage {
/// Message content (max 500 chars).
content: String,
},
/// Drop a prop from inventory to the canvas.
DropProp {
/// Inventory item ID to drop.
inventory_item_id: Uuid,
},
/// Pick up a loose prop from the canvas.
PickUpProp {
/// Loose prop ID to pick up.
loose_prop_id: Uuid,
},
}
/// Server-to-client WebSocket messages.
@ -79,8 +91,8 @@ pub enum ServerMessage {
user_id: Option<Uuid>,
/// Guest session ID (if guest).
guest_session_id: Option<Uuid>,
/// New emotion slot (0-9).
emotion: u8,
/// Emotion name (e.g., "happy", "sad", "neutral").
emotion: String,
/// Asset paths for all 9 positions of the new emotion layer.
emotion_layer: [Option<String>; 9],
},
@ -108,8 +120,8 @@ pub enum ServerMessage {
display_name: String,
/// Message content.
content: String,
/// Current emotion of sender (0-11) for bubble styling.
emotion: u8,
/// Emotion name for bubble styling (e.g., "happy", "sad", "neutral").
emotion: String,
/// Sender's X position at time of message.
x: f64,
/// Sender's Y position at time of message.
@ -117,4 +129,32 @@ pub enum ServerMessage {
/// Server timestamp (milliseconds since epoch).
timestamp: i64,
},
/// Initial list of loose props when joining channel.
LoosePropsSync {
/// All current loose props in the channel.
props: Vec<LooseProp>,
},
/// A prop was dropped on the canvas.
PropDropped {
/// The dropped prop.
prop: LooseProp,
},
/// A prop was picked up from the canvas.
PropPickedUp {
/// ID of the prop that was picked up.
prop_id: Uuid,
/// User ID who picked it up (if authenticated).
picked_up_by_user_id: Option<Uuid>,
/// Guest session ID who picked it up (if guest).
picked_up_by_guest_id: Option<Uuid>,
},
/// A prop expired and was removed.
PropExpired {
/// ID of the expired prop.
prop_id: Uuid,
},
}