Now we have a concept of an avatar at the server, realm, and scene level
and we have the groundwork for a realm store. New uesrs no longer props,
they get a default avatar. New system supports gender
{male,female,neutral} and {child,adult}.
302 lines
8.7 KiB
Rust
302 lines
8.7 KiB
Rust
//! Channel member queries for user presence in channels.
|
|
|
|
use sqlx::PgExecutor;
|
|
use uuid::Uuid;
|
|
|
|
use crate::models::{ChannelMember, ChannelMemberInfo};
|
|
use chattyness_error::AppError;
|
|
|
|
/// Join a channel as an authenticated user.
|
|
///
|
|
/// Restores the user's last position if they were previously in the same scene,
|
|
/// otherwise uses the default position (400, 300).
|
|
/// Note: channel_id is actually scene_id in this system (scenes are used directly as channels).
|
|
pub async fn join_channel<'e>(
|
|
executor: impl PgExecutor<'e>,
|
|
channel_id: Uuid,
|
|
user_id: Uuid,
|
|
) -> Result<ChannelMember, AppError> {
|
|
// Note: channel_id is actually scene_id in this system
|
|
let member = sqlx::query_as::<_, ChannelMember>(
|
|
r#"
|
|
INSERT INTO scene.instance_members (instance_id, user_id, position)
|
|
SELECT $1, $2, COALESCE(
|
|
-- Try to restore last position if user was in the same scene
|
|
-- Note: instance_id = scene_id in this system
|
|
(SELECT m.last_position
|
|
FROM realm.memberships m
|
|
JOIN realm.scenes s ON s.id = $1
|
|
WHERE m.user_id = $2
|
|
AND m.realm_id = s.realm_id
|
|
AND m.last_scene_id = $1
|
|
AND m.last_position IS NOT NULL),
|
|
-- Default position
|
|
ST_SetSRID(ST_MakePoint(400, 300), 0)
|
|
)
|
|
ON CONFLICT (instance_id, user_id) DO UPDATE
|
|
SET joined_at = now()
|
|
RETURNING
|
|
id,
|
|
instance_id as channel_id,
|
|
user_id,
|
|
guest_session_id,
|
|
ST_X(position) as position_x,
|
|
ST_Y(position) as position_y,
|
|
facing_direction,
|
|
is_moving,
|
|
is_afk,
|
|
joined_at,
|
|
last_moved_at
|
|
"#,
|
|
)
|
|
.bind(channel_id)
|
|
.bind(user_id)
|
|
.fetch_one(executor)
|
|
.await?;
|
|
|
|
Ok(member)
|
|
}
|
|
|
|
/// Ensure an active avatar exists for a user in a realm.
|
|
/// If user has a custom avatar (slot 0), use it. Otherwise, avatar_id is NULL
|
|
/// and the system will use server/realm default avatars based on user preferences.
|
|
pub async fn ensure_active_avatar<'e>(
|
|
executor: impl PgExecutor<'e>,
|
|
user_id: Uuid,
|
|
realm_id: Uuid,
|
|
) -> Result<(), AppError> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO auth.active_avatars (user_id, realm_id, avatar_id, current_emotion)
|
|
SELECT $1, $2,
|
|
(SELECT id FROM auth.avatars WHERE user_id = $1 AND slot_number = 0),
|
|
'happy'::server.emotion_state
|
|
ON CONFLICT (user_id, realm_id) DO NOTHING
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.bind(realm_id)
|
|
.execute(executor)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Leave a channel.
|
|
///
|
|
/// Saves the user's current position to memberships.last_position before removing them.
|
|
/// Note: channel_id is actually scene_id in this system (scenes are used directly as channels).
|
|
pub async fn leave_channel<'e>(
|
|
executor: impl PgExecutor<'e>,
|
|
channel_id: Uuid,
|
|
user_id: Uuid,
|
|
) -> Result<(), AppError> {
|
|
// Use data-modifying CTEs with RETURNING to ensure all CTEs execute
|
|
// Note: channel_id is actually scene_id in this system
|
|
sqlx::query(
|
|
r#"
|
|
WITH member_info AS (
|
|
SELECT cm.position, cm.instance_id as scene_id, s.realm_id
|
|
FROM scene.instance_members cm
|
|
JOIN realm.scenes s ON cm.instance_id = s.id
|
|
WHERE cm.instance_id = $1 AND cm.user_id = $2
|
|
),
|
|
save_position AS (
|
|
UPDATE realm.memberships m
|
|
SET last_position = mi.position,
|
|
last_scene_id = mi.scene_id,
|
|
last_visited_at = now()
|
|
FROM member_info mi
|
|
WHERE m.realm_id = mi.realm_id AND m.user_id = $2
|
|
RETURNING m.user_id
|
|
),
|
|
do_delete AS (
|
|
DELETE FROM scene.instance_members
|
|
WHERE instance_id = $1 AND user_id = $2
|
|
RETURNING user_id
|
|
)
|
|
SELECT COUNT(*) FROM save_position, do_delete
|
|
"#,
|
|
)
|
|
.bind(channel_id)
|
|
.bind(user_id)
|
|
.execute(executor)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Update a user's position in a channel.
|
|
pub async fn update_position<'e>(
|
|
executor: impl PgExecutor<'e>,
|
|
channel_id: Uuid,
|
|
user_id: Uuid,
|
|
x: f64,
|
|
y: f64,
|
|
) -> Result<(), AppError> {
|
|
let result = sqlx::query(
|
|
r#"
|
|
UPDATE scene.instance_members
|
|
SET position = ST_SetSRID(ST_MakePoint($3, $4), 0),
|
|
last_moved_at = now(),
|
|
is_moving = true
|
|
WHERE instance_id = $1 AND user_id = $2
|
|
"#,
|
|
)
|
|
.bind(channel_id)
|
|
.bind(user_id)
|
|
.bind(x)
|
|
.bind(y)
|
|
.execute(executor)
|
|
.await?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
return Err(AppError::NotFound("Channel member not found".to_string()));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get all members in a channel with their display info and current emotion.
|
|
pub async fn get_channel_members<'e>(
|
|
executor: impl PgExecutor<'e>,
|
|
channel_id: Uuid,
|
|
realm_id: Uuid,
|
|
) -> Result<Vec<ChannelMemberInfo>, AppError> {
|
|
let members = sqlx::query_as::<_, ChannelMemberInfo>(
|
|
r#"
|
|
SELECT
|
|
cm.id,
|
|
cm.instance_id as channel_id,
|
|
cm.user_id,
|
|
cm.guest_session_id,
|
|
COALESCE(u.display_name, gs.guest_name, 'Anonymous') as display_name,
|
|
ST_X(cm.position) as position_x,
|
|
ST_Y(cm.position) as position_y,
|
|
cm.facing_direction,
|
|
cm.is_moving,
|
|
cm.is_afk,
|
|
COALESCE(aa.current_emotion, 'happy'::server.emotion_state) as current_emotion,
|
|
cm.joined_at,
|
|
COALESCE('guest' = ANY(u.tags), false) as is_guest
|
|
FROM scene.instance_members cm
|
|
LEFT JOIN auth.users u ON cm.user_id = u.id
|
|
LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id
|
|
LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $2
|
|
WHERE cm.instance_id = $1
|
|
ORDER BY cm.joined_at ASC
|
|
"#,
|
|
)
|
|
.bind(channel_id)
|
|
.bind(realm_id)
|
|
.fetch_all(executor)
|
|
.await?;
|
|
|
|
Ok(members)
|
|
}
|
|
|
|
/// Get a specific channel member by user ID.
|
|
pub async fn get_channel_member<'e>(
|
|
executor: impl PgExecutor<'e>,
|
|
channel_id: Uuid,
|
|
user_id: Uuid,
|
|
realm_id: Uuid,
|
|
) -> Result<Option<ChannelMemberInfo>, AppError> {
|
|
let member = sqlx::query_as::<_, ChannelMemberInfo>(
|
|
r#"
|
|
SELECT
|
|
cm.id,
|
|
cm.instance_id as channel_id,
|
|
cm.user_id,
|
|
cm.guest_session_id,
|
|
COALESCE(u.display_name, 'Anonymous') as display_name,
|
|
ST_X(cm.position) as position_x,
|
|
ST_Y(cm.position) as position_y,
|
|
cm.facing_direction,
|
|
cm.is_moving,
|
|
cm.is_afk,
|
|
COALESCE(aa.current_emotion, 'happy'::server.emotion_state) as current_emotion,
|
|
cm.joined_at,
|
|
COALESCE('guest' = ANY(u.tags), false) as is_guest
|
|
FROM scene.instance_members cm
|
|
LEFT JOIN auth.users u ON cm.user_id = u.id
|
|
LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3
|
|
WHERE cm.instance_id = $1 AND cm.user_id = $2
|
|
"#,
|
|
)
|
|
.bind(channel_id)
|
|
.bind(user_id)
|
|
.bind(realm_id)
|
|
.fetch_optional(executor)
|
|
.await?;
|
|
|
|
Ok(member)
|
|
}
|
|
|
|
/// Set a user's moving state to false (called after movement animation completes).
|
|
pub async fn set_stopped<'e>(
|
|
executor: impl PgExecutor<'e>,
|
|
channel_id: Uuid,
|
|
user_id: Uuid,
|
|
) -> Result<(), AppError> {
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE scene.instance_members
|
|
SET is_moving = false
|
|
WHERE instance_id = $1 AND user_id = $2
|
|
"#,
|
|
)
|
|
.bind(channel_id)
|
|
.bind(user_id)
|
|
.execute(executor)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Set a user's AFK state.
|
|
pub async fn set_afk<'e>(
|
|
executor: impl PgExecutor<'e>,
|
|
channel_id: Uuid,
|
|
user_id: Uuid,
|
|
is_afk: bool,
|
|
) -> Result<(), AppError> {
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE scene.instance_members
|
|
SET is_afk = $3
|
|
WHERE instance_id = $1 AND user_id = $2
|
|
"#,
|
|
)
|
|
.bind(channel_id)
|
|
.bind(user_id)
|
|
.bind(is_afk)
|
|
.execute(executor)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Update the last_moved_at timestamp to keep the member alive.
|
|
///
|
|
/// Called when a ping is received from the client to prevent
|
|
/// the member from being reaped by the stale member cleanup.
|
|
pub async fn touch_member<'e>(
|
|
executor: impl PgExecutor<'e>,
|
|
channel_id: Uuid,
|
|
user_id: Uuid,
|
|
) -> Result<(), AppError> {
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE scene.instance_members
|
|
SET last_moved_at = now()
|
|
WHERE instance_id = $1 AND user_id = $2
|
|
"#,
|
|
)
|
|
.bind(channel_id)
|
|
.bind(user_id)
|
|
.execute(executor)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|