Rework avatars.
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}.
This commit is contained in:
parent
e4abdb183f
commit
6fb90e42c3
55 changed files with 7392 additions and 512 deletions
|
|
@ -18,14 +18,33 @@ use tokio::sync::{broadcast, mpsc};
|
|||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::{
|
||||
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
|
||||
queries::{avatars, channel_members, loose_props, memberships, moderation, realms, scenes, users},
|
||||
models::{ActionType, AvatarRenderData, ChannelMemberWithAvatar, EmotionState, ForcedAvatarReason, User},
|
||||
queries::{avatars, channel_members, loose_props, memberships, moderation, realm_avatars, realms, scenes, server_avatars, users},
|
||||
ws_messages::{close_codes, ClientMessage, DisconnectReason, ServerMessage, WsConfig},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
use chattyness_shared::WebSocketConfig;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
|
||||
/// Parse a duration string like "30m", "2h", "7d" into a chrono::Duration.
|
||||
fn parse_duration(s: &str) -> Option<ChronoDuration> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (num_str, unit) = s.split_at(s.len() - 1);
|
||||
let num: i64 = num_str.parse().ok()?;
|
||||
|
||||
match unit {
|
||||
"m" => Some(ChronoDuration::minutes(num)),
|
||||
"h" => Some(ChronoDuration::hours(num)),
|
||||
"d" => Some(ChronoDuration::days(num)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel state for broadcasting updates.
|
||||
pub struct ChannelState {
|
||||
|
|
@ -271,8 +290,22 @@ async fn handle_socket(
|
|||
}
|
||||
tracing::info!("[WS] Channel joined");
|
||||
|
||||
// Check if scene has forced avatar
|
||||
if let Ok(Some(scene_forced)) = realm_avatars::get_scene_forced_avatar(&pool, channel_id).await {
|
||||
tracing::info!("[WS] Scene has forced avatar: {:?}", scene_forced.forced_avatar_id);
|
||||
// Apply scene-forced avatar to user
|
||||
if let Err(e) = realm_avatars::apply_scene_forced_avatar(
|
||||
&pool,
|
||||
user.id,
|
||||
realm_id,
|
||||
scene_forced.forced_avatar_id,
|
||||
).await {
|
||||
tracing::warn!("[WS] Failed to apply scene forced avatar: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get initial state
|
||||
let members = match get_members_with_avatars(&mut conn, channel_id, realm_id).await {
|
||||
let members = match get_members_with_avatars(&mut conn, &pool, channel_id, realm_id).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::error!("[WS] Failed to get members: {:?}", e);
|
||||
|
|
@ -338,11 +371,13 @@ async fn handle_socket(
|
|||
let member_display_name = member.display_name.clone();
|
||||
|
||||
// Broadcast join to others
|
||||
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id)
|
||||
// Use effective avatar resolution to handle priority chain:
|
||||
// forced > custom > selected realm > selected server > realm default > server default
|
||||
let avatar = avatars::get_effective_avatar_render_data(&pool, user.id, realm_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|a| a.to_render_data())
|
||||
.map(|(render_data, _source)| render_data)
|
||||
.unwrap_or_default();
|
||||
let join_msg = ServerMessage::MemberJoined {
|
||||
member: ChannelMemberWithAvatar { member, avatar },
|
||||
|
|
@ -458,6 +493,7 @@ async fn handle_socket(
|
|||
};
|
||||
let emotion_layer = match avatars::set_emotion(
|
||||
&mut *recv_conn,
|
||||
&pool,
|
||||
user_id,
|
||||
realm_id,
|
||||
emotion_state,
|
||||
|
|
@ -711,16 +747,16 @@ async fn handle_socket(
|
|||
}
|
||||
}
|
||||
ClientMessage::SyncAvatar => {
|
||||
// Fetch current avatar from database and broadcast to channel
|
||||
match avatars::get_avatar_with_paths_conn(
|
||||
&mut *recv_conn,
|
||||
// Fetch current effective avatar from database and broadcast to channel
|
||||
// Uses the priority chain: forced > custom > selected realm > selected server > realm default > server default
|
||||
match avatars::get_effective_avatar_render_data(
|
||||
&pool,
|
||||
user_id,
|
||||
realm_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(avatar_with_paths)) => {
|
||||
let render_data = avatar_with_paths.to_render_data();
|
||||
Ok(Some((render_data, _source))) => {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(
|
||||
"[WS] User {} syncing avatar to channel",
|
||||
|
|
@ -1066,6 +1102,280 @@ async fn handle_socket(
|
|||
}).await;
|
||||
}
|
||||
}
|
||||
"dress" => {
|
||||
// /mod dress [nick] [avatar-slug] [duration?]
|
||||
// Duration format: 30m, 2h, 7d (minutes/hours/days)
|
||||
if args.len() < 2 {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: "Usage: /mod dress [nick] [avatar-slug] [duration?]".to_string(),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let target_nick = &args[0];
|
||||
let avatar_slug = &args[1];
|
||||
let duration_str = args.get(2).map(|s| s.as_str());
|
||||
|
||||
// Parse duration if provided
|
||||
let duration = if let Some(dur_str) = duration_str {
|
||||
match parse_duration(dur_str) {
|
||||
Some(d) => Some(d),
|
||||
None => {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: "Invalid duration format. Use: 30m, 2h, 7d".to_string(),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None // Permanent until undressed
|
||||
};
|
||||
|
||||
// Find target user
|
||||
if let Some((target_user_id, target_conn)) = ws_state
|
||||
.find_user_by_display_name(realm_id, target_nick)
|
||||
{
|
||||
// Try realm avatars first, then server avatars
|
||||
let avatar_result = realm_avatars::get_realm_avatar_by_slug(&pool, realm_id, avatar_slug).await;
|
||||
let (avatar_render_data, avatar_id, source) = match avatar_result {
|
||||
Ok(Some(realm_avatar)) => {
|
||||
// Resolve realm avatar to render data
|
||||
match realm_avatars::resolve_realm_avatar_to_render_data(&pool, &realm_avatar, EmotionState::default()).await {
|
||||
Ok(render_data) => (render_data, realm_avatar.id, "realm"),
|
||||
Err(e) => {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("Failed to resolve avatar: {:?}", e),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// Try server avatars
|
||||
match server_avatars::get_server_avatar_by_slug(&pool, avatar_slug).await {
|
||||
Ok(Some(server_avatar)) => {
|
||||
match server_avatars::resolve_server_avatar_to_render_data(&pool, &server_avatar, EmotionState::default()).await {
|
||||
Ok(render_data) => (render_data, server_avatar.id, "server"),
|
||||
Err(e) => {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("Failed to resolve avatar: {:?}", e),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("Avatar '{}' not found", avatar_slug),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("Failed to lookup avatar: {:?}", e),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("Failed to lookup avatar: {:?}", e),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Acquire connection and set RLS context for the update
|
||||
let mut rls_conn = match pool.acquire().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("Database connection error: {:?}", e),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = set_rls_user_id(&mut rls_conn, user_id).await {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("Failed to set RLS context: {:?}", e),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply forced avatar using connection with RLS context
|
||||
let apply_result = if source == "server" {
|
||||
server_avatars::apply_forced_server_avatar(
|
||||
&mut *rls_conn,
|
||||
target_user_id,
|
||||
realm_id,
|
||||
avatar_id,
|
||||
Some(user_id),
|
||||
duration,
|
||||
).await
|
||||
} else {
|
||||
realm_avatars::apply_forced_realm_avatar(
|
||||
&mut *rls_conn,
|
||||
target_user_id,
|
||||
realm_id,
|
||||
avatar_id,
|
||||
Some(user_id),
|
||||
duration,
|
||||
).await
|
||||
};
|
||||
|
||||
if let Err(e) = apply_result {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("Failed to apply forced avatar: {:?}", e),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Log moderation action
|
||||
let metadata = serde_json::json!({
|
||||
"avatar_slug": avatar_slug,
|
||||
"avatar_id": avatar_id.to_string(),
|
||||
"source": source,
|
||||
"duration": duration_str,
|
||||
});
|
||||
let _ = moderation::log_moderation_action(
|
||||
&pool,
|
||||
realm_id,
|
||||
user_id,
|
||||
ActionType::DressUser,
|
||||
Some(target_user_id),
|
||||
&format!("Forced {} to wear avatar '{}'", target_nick, avatar_slug),
|
||||
metadata,
|
||||
).await;
|
||||
|
||||
// Send AvatarForced to target user
|
||||
let _ = target_conn.direct_tx.send(ServerMessage::AvatarForced {
|
||||
user_id: target_user_id,
|
||||
avatar: avatar_render_data.clone(),
|
||||
reason: ForcedAvatarReason::ModCommand,
|
||||
forced_by: Some(mod_member.display_name.clone()),
|
||||
}).await;
|
||||
|
||||
// Broadcast avatar update to channel
|
||||
let _ = tx.send(ServerMessage::AvatarUpdated {
|
||||
user_id: Some(target_user_id),
|
||||
guest_session_id: None,
|
||||
avatar: avatar_render_data,
|
||||
});
|
||||
|
||||
let duration_msg = match duration_str {
|
||||
Some(d) => format!(" for {}", d),
|
||||
None => " permanently".to_string(),
|
||||
};
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: true,
|
||||
message: format!("Forced {} to wear '{}'{}", target_nick, avatar_slug, duration_msg),
|
||||
}).await;
|
||||
} else {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("User '{}' is not online in this realm", target_nick),
|
||||
}).await;
|
||||
}
|
||||
}
|
||||
"undress" => {
|
||||
// /mod undress [nick]
|
||||
if args.is_empty() {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: "Usage: /mod undress [nick]".to_string(),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let target_nick = &args[0];
|
||||
|
||||
// Find target user
|
||||
if let Some((target_user_id, target_conn)) = ws_state
|
||||
.find_user_by_display_name(realm_id, target_nick)
|
||||
{
|
||||
// Acquire connection and set RLS context for the update
|
||||
let mut rls_conn = match pool.acquire().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("Database connection error: {:?}", e),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = set_rls_user_id(&mut rls_conn, user_id).await {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("Failed to set RLS context: {:?}", e),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clear forced avatar using connection with RLS context
|
||||
if let Err(e) = server_avatars::clear_forced_avatar(&mut *rls_conn, target_user_id, realm_id).await {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("Failed to clear forced avatar: {:?}", e),
|
||||
}).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the user's original avatar
|
||||
let original_avatar = match avatars::get_avatar_with_paths(&pool, target_user_id, realm_id).await {
|
||||
Ok(Some(avatar)) => avatar.to_render_data(),
|
||||
Ok(None) => AvatarRenderData::default(),
|
||||
Err(_) => AvatarRenderData::default(),
|
||||
};
|
||||
|
||||
// Log moderation action
|
||||
let metadata = serde_json::json!({});
|
||||
let _ = moderation::log_moderation_action(
|
||||
&pool,
|
||||
realm_id,
|
||||
user_id,
|
||||
ActionType::UndressUser,
|
||||
Some(target_user_id),
|
||||
&format!("Cleared forced avatar for {}", target_nick),
|
||||
metadata,
|
||||
).await;
|
||||
|
||||
// Send AvatarCleared to target user
|
||||
let _ = target_conn.direct_tx.send(ServerMessage::AvatarCleared {
|
||||
user_id: target_user_id,
|
||||
avatar: original_avatar.clone(),
|
||||
cleared_by: Some(mod_member.display_name.clone()),
|
||||
}).await;
|
||||
|
||||
// Broadcast avatar update to channel
|
||||
let _ = tx.send(ServerMessage::AvatarUpdated {
|
||||
user_id: Some(target_user_id),
|
||||
guest_session_id: None,
|
||||
avatar: original_avatar,
|
||||
});
|
||||
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: true,
|
||||
message: format!("Cleared forced avatar for {}", target_nick),
|
||||
}).await;
|
||||
} else {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
message: format!("User '{}' is not online in this realm", target_nick),
|
||||
}).await;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = direct_tx.send(ServerMessage::ModCommandResult {
|
||||
success: false,
|
||||
|
|
@ -1233,23 +1543,24 @@ async fn handle_socket(
|
|||
/// Helper: Get all channel members with their avatar render data.
|
||||
async fn get_members_with_avatars(
|
||||
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||
pool: &PgPool,
|
||||
channel_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
) -> Result<Vec<ChannelMemberWithAvatar>, AppError> {
|
||||
// Get members first
|
||||
let members = channel_members::get_channel_members(&mut **conn, channel_id, realm_id).await?;
|
||||
|
||||
// Fetch avatar data for each member using full avatar with paths
|
||||
// This avoids the CASE statement approach and handles all emotions correctly
|
||||
// Fetch avatar data for each member using the effective avatar resolution
|
||||
// This handles the priority chain: forced > custom > selected realm > selected server > realm default > server default
|
||||
let mut result = Vec::with_capacity(members.len());
|
||||
for member in members {
|
||||
let avatar = if let Some(user_id) = member.user_id {
|
||||
// Get full avatar and convert to render data for current emotion
|
||||
avatars::get_avatar_with_paths_conn(&mut **conn, user_id, realm_id)
|
||||
// Use the new effective avatar resolution which handles all priority levels
|
||||
avatars::get_effective_avatar_render_data(pool, user_id, realm_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|a| a.to_render_data())
|
||||
.map(|(render_data, _source)| render_data)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
// Guest users don't have avatars
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue