make emotions named instead, add drop prop
This commit is contained in:
parent
989e20757b
commit
ea3b444d71
19 changed files with 1429 additions and 150 deletions
|
|
@ -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))]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
||||
|
|
|
|||
43
crates/chattyness-db/src/queries/channels.rs
Normal file
43
crates/chattyness-db/src/queries/channels.rs
Normal 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)
|
||||
}
|
||||
62
crates/chattyness-db/src/queries/inventory.rs
Normal file
62
crates/chattyness-db/src/queries/inventory.rs
Normal 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(())
|
||||
}
|
||||
217
crates/chattyness-db/src/queries/loose_props.rs
Normal file
217
crates/chattyness-db/src/queries/loose_props.rs
Normal 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())
|
||||
}
|
||||
|
|
@ -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
|
||||
"#,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue