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:
Evan Carroll 2026-01-22 21:04:27 -06:00
parent e4abdb183f
commit 6fb90e42c3
55 changed files with 7392 additions and 512 deletions

View file

@ -16,7 +16,11 @@ pub async fn get_active_avatar<'e>(
) -> Result<Option<ActiveAvatar>, AppError> {
let avatar = sqlx::query_as::<_, ActiveAvatar>(
r#"
SELECT user_id, realm_id, avatar_id, current_emotion, updated_at
SELECT
user_id, realm_id, avatar_id,
selected_server_avatar_id, selected_realm_avatar_id,
current_emotion, updated_at,
forced_avatar_id, forced_avatar_source, forced_by, forced_until
FROM auth.active_avatars
WHERE user_id = $1 AND realm_id = $2
"#,
@ -31,86 +35,54 @@ pub async fn get_active_avatar<'e>(
/// Set the current emotion for a user in a realm.
/// Returns the full emotion layer (9 asset paths) for the new emotion.
///
/// This function works with any avatar source:
/// - Custom user avatars (auth.avatars)
/// - Selected server avatars (server.avatars)
/// - Selected realm avatars (realm.avatars)
/// - Server default avatars (server.avatars via server.config)
/// - Realm default avatars (realm.avatars via realm.realms)
///
/// Takes both a connection (for RLS-protected update) and a pool (for avatar resolution).
pub async fn set_emotion<'e>(
executor: impl PgExecutor<'e>,
conn: &mut PgConnection,
pool: &PgPool,
user_id: Uuid,
realm_id: Uuid,
emotion: EmotionState,
) -> Result<[Option<String>; 9], AppError> {
// Map emotion to column prefix
let emotion_prefix = match emotion {
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!(
// First, update the emotion in active_avatars (uses RLS connection)
let update_result = sqlx::query(
r#"
WITH updated AS (
UPDATE auth.active_avatars
SET current_emotion = $3, updated_at = now()
WHERE user_id = $1 AND realm_id = $2
RETURNING avatar_id
)
SELECT
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_0) as p0,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_1) as p1,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_2) as p2,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_3) as p3,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_4) as p4,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_5) as p5,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_6) as p6,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_7) as p7,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_8) as p8
FROM updated u
JOIN auth.avatars a ON a.id = u.avatar_id
UPDATE auth.active_avatars
SET current_emotion = $3::server.emotion_state, updated_at = now()
WHERE user_id = $1 AND realm_id = $2
"#,
prefix = emotion_prefix
);
)
.bind(user_id)
.bind(realm_id)
.bind(emotion.to_string())
.execute(&mut *conn)
.await?;
let result = sqlx::query_as::<_, EmotionLayerRow>(&query)
.bind(user_id)
.bind(realm_id)
.bind(emotion_index)
.fetch_optional(executor)
.await?;
match result {
Some(row) => Ok([
row.p0, row.p1, row.p2, row.p3, row.p4, row.p5, row.p6, row.p7, row.p8,
]),
None => Err(AppError::NotFound(
if update_result.rows_affected() == 0 {
return Err(AppError::NotFound(
"No active avatar for this user in this realm".to_string(),
)),
));
}
// Now get the effective avatar and return the emotion layer (uses pool for multiple queries)
let render_data = get_effective_avatar_render_data(pool, user_id, realm_id).await?;
match render_data {
Some((data, _source)) => Ok(data.emotion_layer),
None => {
// No avatar found - return empty layer
Ok([None, None, None, None, None, None, None, None, None])
}
}
}
/// Row type for emotion layer query.
#[derive(Debug, sqlx::FromRow)]
struct EmotionLayerRow {
p0: Option<String>,
p1: Option<String>,
p2: Option<String>,
p3: Option<String>,
p4: Option<String>,
p5: Option<String>,
p6: Option<String>,
p7: Option<String>,
p8: Option<String>,
}
/// Get emotion availability for a user's avatar in a realm.
///
@ -1469,7 +1441,7 @@ fn collect_uuids(dest: &mut Vec<Uuid>, sources: &[Option<Uuid>]) {
#[derive(Debug, sqlx::FromRow)]
struct AvatarWithEmotion {
pub id: Uuid,
pub current_emotion: i16,
pub current_emotion: EmotionState,
// Content layers
pub l_skin_0: Option<Uuid>,
pub l_skin_1: Option<Uuid>,
@ -1617,22 +1589,18 @@ pub async fn set_emotion_simple<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
emotion: i16,
emotion: EmotionState,
) -> Result<(), AppError> {
if emotion < 0 || emotion > 11 {
return Err(AppError::Validation("Emotion must be 0-11".to_string()));
}
let result = sqlx::query(
r#"
UPDATE auth.active_avatars
SET current_emotion = $3, updated_at = now()
SET current_emotion = $3::server.emotion_state, updated_at = now()
WHERE user_id = $1 AND realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.bind(emotion)
.bind(emotion.to_string())
.execute(executor)
.await?;
@ -1723,3 +1691,472 @@ pub async fn update_avatar_slot(
Ok(())
}
/// Data needed to resolve effective avatar for a user.
#[derive(Debug, sqlx::FromRow)]
pub struct AvatarResolutionContext {
// Active avatar row data
pub avatar_id: Option<Uuid>,
pub selected_server_avatar_id: Option<Uuid>,
pub selected_realm_avatar_id: Option<Uuid>,
pub current_emotion: EmotionState,
// Forced avatar data
pub forced_avatar_id: Option<Uuid>,
pub forced_avatar_source: Option<String>,
pub forced_until: Option<chrono::DateTime<chrono::Utc>>,
// User preferences
pub gender_preference: crate::models::GenderPreference,
pub age_category: crate::models::AgeCategory,
}
/// Source of the resolved avatar.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AvatarSource {
/// User's custom avatar from auth.avatars
Custom,
/// User-selected realm avatar from avatar store
SelectedRealm,
/// User-selected server avatar from avatar store
SelectedServer,
/// Realm default avatar based on gender/age
RealmDefault,
/// Server default avatar based on gender/age
ServerDefault,
/// Forced avatar (mod command or scene)
Forced,
}
/// Get the effective avatar render data for a user in a realm.
///
/// This function implements the avatar resolution priority chain:
/// 1. Forced avatar (mod command or scene) - highest priority
/// 2. User's custom avatar (auth.avatars via avatar_id)
/// 3. User-selected realm avatar (selected_realm_avatar_id)
/// 4. User-selected server avatar (selected_server_avatar_id)
/// 5. Realm default (based on gender+age)
/// 6. Server default (based on gender+age) - lowest priority
pub async fn get_effective_avatar_render_data<'e>(
executor: impl PgExecutor<'e> + Copy,
user_id: Uuid,
realm_id: Uuid,
) -> Result<Option<(crate::models::AvatarRenderData, AvatarSource)>, AppError> {
// Get the resolution context with all necessary data
// Use LEFT JOIN so we can still get user preferences even without an active_avatars entry
let ctx = sqlx::query_as::<_, AvatarResolutionContext>(
r#"
SELECT
aa.avatar_id,
aa.selected_server_avatar_id,
aa.selected_realm_avatar_id,
COALESCE(aa.current_emotion, 'happy'::server.emotion_state) as current_emotion,
aa.forced_avatar_id,
aa.forced_avatar_source,
aa.forced_until,
u.gender_preference,
u.age_category
FROM auth.users u
LEFT JOIN auth.active_avatars aa ON aa.user_id = u.id AND aa.realm_id = $2
WHERE u.id = $1
"#,
)
.bind(user_id)
.bind(realm_id)
.fetch_optional(executor)
.await?;
let Some(ctx) = ctx else {
// User doesn't exist
return Ok(None);
};
// Priority 1: Check for forced avatar (not expired)
if let Some(forced_id) = ctx.forced_avatar_id {
let is_expired = ctx.forced_until.map(|t| t < chrono::Utc::now()).unwrap_or(false);
if !is_expired {
if let Some(source) = &ctx.forced_avatar_source {
match source.as_str() {
"server" | "scene" => {
// Resolve from server.avatars
if let Some(avatar) = super::server_avatars::get_server_avatar_by_id(executor, forced_id).await? {
let render = super::server_avatars::resolve_server_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::Forced)));
}
}
"realm" => {
// Resolve from realm.avatars
if let Some(avatar) = super::realm_avatars::get_realm_avatar_by_id(executor, forced_id).await? {
let render = super::realm_avatars::resolve_realm_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::Forced)));
}
}
_ => {}
}
}
}
}
// Priority 2: User's custom avatar
if let Some(avatar_id) = ctx.avatar_id {
if let Some(render) = resolve_user_avatar_to_render_data(executor, avatar_id, ctx.current_emotion).await? {
return Ok(Some((render, AvatarSource::Custom)));
}
}
// Priority 3: User-selected realm avatar
if let Some(realm_avatar_id) = ctx.selected_realm_avatar_id {
if let Some(avatar) = super::realm_avatars::get_realm_avatar_by_id(executor, realm_avatar_id).await? {
let render = super::realm_avatars::resolve_realm_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::SelectedRealm)));
}
}
// Priority 4: User-selected server avatar
if let Some(server_avatar_id) = ctx.selected_server_avatar_id {
if let Some(avatar) = super::server_avatars::get_server_avatar_by_id(executor, server_avatar_id).await? {
let render = super::server_avatars::resolve_server_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::SelectedServer)));
}
}
// Priority 5: Realm default avatar (based on gender+age)
let realm_default_id = get_realm_default_avatar_id(executor, realm_id, ctx.gender_preference, ctx.age_category).await?;
if let Some(avatar_id) = realm_default_id {
if let Some(avatar) = super::realm_avatars::get_realm_avatar_by_id(executor, avatar_id).await? {
let render = super::realm_avatars::resolve_realm_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::RealmDefault)));
}
}
// Priority 6: Server default avatar (based on gender+age)
let server_default_id = get_server_default_avatar_id(executor, ctx.gender_preference, ctx.age_category).await?;
if let Some(avatar_id) = server_default_id {
if let Some(avatar) = super::server_avatars::get_server_avatar_by_id(executor, avatar_id).await? {
let render = super::server_avatars::resolve_server_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::ServerDefault)));
}
}
Ok(None)
}
/// Resolve a user's custom avatar (from auth.avatars) to render data.
async fn resolve_user_avatar_to_render_data<'e>(
executor: impl PgExecutor<'e> + Copy,
avatar_id: Uuid,
current_emotion: EmotionState,
) -> Result<Option<crate::models::AvatarRenderData>, AppError> {
// Get the avatar with inventory joins
let avatar = sqlx::query_as::<_, AvatarWithEmotion>(
r#"
SELECT
a.id, $2::server.emotion_state as current_emotion,
a.l_skin_0, a.l_skin_1, a.l_skin_2, a.l_skin_3, a.l_skin_4,
a.l_skin_5, a.l_skin_6, a.l_skin_7, a.l_skin_8,
a.l_clothes_0, a.l_clothes_1, a.l_clothes_2, a.l_clothes_3, a.l_clothes_4,
a.l_clothes_5, a.l_clothes_6, a.l_clothes_7, a.l_clothes_8,
a.l_accessories_0, a.l_accessories_1, a.l_accessories_2, a.l_accessories_3, a.l_accessories_4,
a.l_accessories_5, a.l_accessories_6, a.l_accessories_7, a.l_accessories_8,
a.e_neutral_0, a.e_neutral_1, a.e_neutral_2, a.e_neutral_3, a.e_neutral_4,
a.e_neutral_5, a.e_neutral_6, a.e_neutral_7, a.e_neutral_8,
a.e_happy_0, a.e_happy_1, a.e_happy_2, a.e_happy_3, a.e_happy_4,
a.e_happy_5, a.e_happy_6, a.e_happy_7, a.e_happy_8,
a.e_sad_0, a.e_sad_1, a.e_sad_2, a.e_sad_3, a.e_sad_4,
a.e_sad_5, a.e_sad_6, a.e_sad_7, a.e_sad_8,
a.e_angry_0, a.e_angry_1, a.e_angry_2, a.e_angry_3, a.e_angry_4,
a.e_angry_5, a.e_angry_6, a.e_angry_7, a.e_angry_8,
a.e_surprised_0, a.e_surprised_1, a.e_surprised_2, a.e_surprised_3, a.e_surprised_4,
a.e_surprised_5, a.e_surprised_6, a.e_surprised_7, a.e_surprised_8,
a.e_thinking_0, a.e_thinking_1, a.e_thinking_2, a.e_thinking_3, a.e_thinking_4,
a.e_thinking_5, a.e_thinking_6, a.e_thinking_7, a.e_thinking_8,
a.e_laughing_0, a.e_laughing_1, a.e_laughing_2, a.e_laughing_3, a.e_laughing_4,
a.e_laughing_5, a.e_laughing_6, a.e_laughing_7, a.e_laughing_8,
a.e_crying_0, a.e_crying_1, a.e_crying_2, a.e_crying_3, a.e_crying_4,
a.e_crying_5, a.e_crying_6, a.e_crying_7, a.e_crying_8,
a.e_love_0, a.e_love_1, a.e_love_2, a.e_love_3, a.e_love_4,
a.e_love_5, a.e_love_6, a.e_love_7, a.e_love_8,
a.e_confused_0, a.e_confused_1, a.e_confused_2, a.e_confused_3, a.e_confused_4,
a.e_confused_5, a.e_confused_6, a.e_confused_7, a.e_confused_8,
a.e_sleeping_0, a.e_sleeping_1, a.e_sleeping_2, a.e_sleeping_3, a.e_sleeping_4,
a.e_sleeping_5, a.e_sleeping_6, a.e_sleeping_7, a.e_sleeping_8,
a.e_wink_0, a.e_wink_1, a.e_wink_2, a.e_wink_3, a.e_wink_4,
a.e_wink_5, a.e_wink_6, a.e_wink_7, a.e_wink_8
FROM auth.avatars a
WHERE a.id = $1
"#,
)
.bind(avatar_id)
.bind(current_emotion)
.fetch_optional(executor)
.await?;
let Some(avatar) = avatar else {
return Ok(None);
};
// Collect all inventory UUIDs
let mut uuids: Vec<Uuid> = Vec::new();
collect_uuids(
&mut uuids,
&[
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,
],
);
collect_uuids(
&mut uuids,
&[
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,
],
);
collect_uuids(
&mut uuids,
&[
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,
],
);
// Get emotion slots for 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],
};
collect_uuids(&mut uuids, &emotion_slots);
// Bulk resolve inventory UUIDs to asset paths
let paths: HashMap<Uuid, String> = if uuids.is_empty() {
HashMap::new()
} else {
sqlx::query_as::<_, (Uuid, String)>(
"SELECT id, prop_asset_path FROM auth.inventory WHERE id = ANY($1)",
)
.bind(&uuids)
.fetch_all(executor)
.await?
.into_iter()
.collect()
};
let get_path = |id: Option<Uuid>| -> Option<String> {
id.and_then(|id| paths.get(&id).cloned())
};
Ok(Some(crate::models::AvatarRenderData {
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]),
],
}))
}
/// Get the realm default avatar ID based on gender and age preferences.
async fn get_realm_default_avatar_id<'e>(
executor: impl PgExecutor<'e>,
realm_id: Uuid,
gender: crate::models::GenderPreference,
age: crate::models::AgeCategory,
) -> Result<Option<Uuid>, AppError> {
use crate::models::{AgeCategory, GenderPreference};
// Build column name based on gender and age
let column = match (gender, age) {
(GenderPreference::GenderNeutral, AgeCategory::Child) => "default_avatar_neutral_child",
(GenderPreference::GenderNeutral, AgeCategory::Adult) => "default_avatar_neutral_adult",
(GenderPreference::GenderMale, AgeCategory::Child) => "default_avatar_male_child",
(GenderPreference::GenderMale, AgeCategory::Adult) => "default_avatar_male_adult",
(GenderPreference::GenderFemale, AgeCategory::Child) => "default_avatar_female_child",
(GenderPreference::GenderFemale, AgeCategory::Adult) => "default_avatar_female_adult",
};
let query = format!(
"SELECT {} FROM realm.realms WHERE id = $1",
column
);
let result: Option<(Option<Uuid>,)> = sqlx::query_as(&query)
.bind(realm_id)
.fetch_optional(executor)
.await?;
Ok(result.and_then(|r| r.0))
}
/// Get the server default avatar ID based on gender and age preferences.
async fn get_server_default_avatar_id<'e>(
executor: impl PgExecutor<'e>,
gender: crate::models::GenderPreference,
age: crate::models::AgeCategory,
) -> Result<Option<Uuid>, AppError> {
use crate::models::{AgeCategory, GenderPreference};
// Build column name based on gender and age
let column = match (gender, age) {
(GenderPreference::GenderNeutral, AgeCategory::Child) => "default_avatar_neutral_child",
(GenderPreference::GenderNeutral, AgeCategory::Adult) => "default_avatar_neutral_adult",
(GenderPreference::GenderMale, AgeCategory::Child) => "default_avatar_male_child",
(GenderPreference::GenderMale, AgeCategory::Adult) => "default_avatar_male_adult",
(GenderPreference::GenderFemale, AgeCategory::Child) => "default_avatar_female_child",
(GenderPreference::GenderFemale, AgeCategory::Adult) => "default_avatar_female_adult",
};
let query = format!(
"SELECT {} FROM server.config WHERE id = '00000000-0000-0000-0000-000000000001'",
column
);
let result: Option<(Option<Uuid>,)> = sqlx::query_as(&query)
.fetch_optional(executor)
.await?;
Ok(result.and_then(|r| r.0))
}
/// Select a server avatar for a user in a realm.
/// This updates the selected_server_avatar_id in active_avatars.
/// Uses UPSERT to create the record if it doesn't exist.
pub async fn select_server_avatar<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
server_avatar_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO auth.active_avatars (user_id, realm_id, selected_server_avatar_id, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id, realm_id) DO UPDATE
SET selected_server_avatar_id = EXCLUDED.selected_server_avatar_id,
updated_at = EXCLUDED.updated_at
"#,
)
.bind(user_id)
.bind(realm_id)
.bind(server_avatar_id)
.execute(executor)
.await?;
Ok(())
}
/// Select a realm avatar for a user in a realm.
/// This updates the selected_realm_avatar_id in active_avatars.
/// Uses UPSERT to create the record if it doesn't exist.
pub async fn select_realm_avatar<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
realm_avatar_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO auth.active_avatars (user_id, realm_id, selected_realm_avatar_id, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id, realm_id) DO UPDATE
SET selected_realm_avatar_id = EXCLUDED.selected_realm_avatar_id,
updated_at = EXCLUDED.updated_at
"#,
)
.bind(user_id)
.bind(realm_id)
.bind(realm_avatar_id)
.execute(executor)
.await?;
Ok(())
}
/// Clear avatar selection for a user in a realm.
/// Clears both selected_server_avatar_id and selected_realm_avatar_id.
/// If no record exists, this is a no-op (clearing nothing is success).
pub async fn clear_avatar_selection<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
UPDATE auth.active_avatars
SET
selected_server_avatar_id = NULL,
selected_realm_avatar_id = NULL,
updated_at = now()
WHERE user_id = $1 AND realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.execute(executor)
.await?;
// No error if record doesn't exist - clearing nothing is success
Ok(())
}