Richer player profiles, launcher command guide, integration docs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
13 files changed
+655
-13
No files matched your search
@@ -672,6 +672,10 @@ const MIGRATIONS: &[&str] = &[
|
||||
r#"
|
||||
ALTER TABLE groups ADD COLUMN discord_role TEXT NOT NULL DEFAULT '';
|
||||
"#,
|
||||
// Profile cosmetics.
|
||||
r#"
|
||||
ALTER TABLE user_profiles ADD COLUMN accent_color TEXT;
|
||||
"#,
|
||||
// Servers can share one economy (balances and guild banks) by sharing a group name.
|
||||
r#"
|
||||
ALTER TABLE game_servers ADD COLUMN economy_group TEXT NOT NULL DEFAULT '';
|
||||
|
||||
@@ -566,7 +566,19 @@ pub async fn get_player_profile(
|
||||
SUM(blocks_placed) blocks_placed, SUM(messages) messages, MIN(first_seen) first_seen, MAX(last_seen) last_seen
|
||||
FROM player_stats WHERE uuid = ? GROUP BY uuid")
|
||||
.bind(&puuid).fetch_optional(&state.db).await?;
|
||||
let extras = profile_extras(&state, &puuid, viewer.as_ref().map(|v| v.uuid.as_str())).await?;
|
||||
Ok(Json(UserProfileView {
|
||||
online: extras.online,
|
||||
last_seen: extras.last_seen,
|
||||
guild: extras.guild,
|
||||
rank: extras.rank,
|
||||
favorite_server: extras.favorite_server,
|
||||
wealth: extras.wealth,
|
||||
recent_activity: extras.recent_activity,
|
||||
mutual_friends: extras.mutual_friends,
|
||||
relationship: extras.relationship,
|
||||
accent_color: extras.accent_color,
|
||||
discord_linked: extras.discord_linked,
|
||||
level_info: levels.clone(),
|
||||
badges: levels.badges.clone(),
|
||||
achievements,
|
||||
@@ -588,8 +600,145 @@ pub async fn get_player_profile(
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ProfileExtras {
|
||||
online: bool,
|
||||
last_seen: Option<String>,
|
||||
guild: Option<scopenet_shared::ProfileGuild>,
|
||||
rank: Option<scopenet_shared::ProfileRank>,
|
||||
favorite_server: Option<String>,
|
||||
wealth: f64,
|
||||
recent_activity: Vec<scopenet_shared::ProfileActivity>,
|
||||
mutual_friends: Vec<scopenet_shared::MutualFriend>,
|
||||
relationship: String,
|
||||
accent_color: Option<String>,
|
||||
discord_linked: bool,
|
||||
}
|
||||
|
||||
/// Everything on a profile beyond the basics: presence, guild, rank, wealth,
|
||||
/// recent activity and how the viewer is connected to the player.
|
||||
async fn profile_extras(state: &AppState, uuid: &str, viewer: Option<&str>) -> AppResult<ProfileExtras> {
|
||||
let db = &state.db;
|
||||
let online: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(uuid).fetch_one(db).await?;
|
||||
let last_seen: Option<String> = sqlx::query_scalar("SELECT MAX(last_seen) FROM player_stats WHERE uuid = ?").bind(uuid).fetch_one(db).await?;
|
||||
|
||||
let guild: Option<(String, String, String, String)> = sqlx::query_as(
|
||||
"SELECT g.id, g.name, g.tag, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? ORDER BY gm.joined_at LIMIT 1",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
// LuckPerms stays authoritative: this is only what a server last reported.
|
||||
let rank: Option<(String, String, String, String)> = sqlx::query_as(
|
||||
"SELECT r.primary_group, r.display, r.prefix, gs.name FROM player_ranks r JOIN game_servers gs ON gs.id = r.server_id
|
||||
WHERE r.uuid = ? AND r.primary_group <> '' ORDER BY r.weight DESC, r.updated_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let favorite_server: Option<String> = sqlx::query_scalar(
|
||||
"SELECT gs.name FROM player_stats ps JOIN game_servers gs ON gs.id = ps.server_id WHERE ps.uuid = ? AND ps.playtime_secs > 0 ORDER BY ps.playtime_secs DESC LIMIT 1",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
// Servers that share an economy group hold one balance between them, so count each pool once.
|
||||
let wealth: f64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(se.balance), 0.0) FROM server_economy se WHERE se.uuid = ? AND se.server_id IN (
|
||||
SELECT COALESCE((SELECT MIN(b.id) FROM game_servers b WHERE b.economy_group <> '' AND b.economy_group = a.economy_group COLLATE NOCASE), a.id) FROM game_servers a)",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
// Only moments worth showing; chat and commands stay private.
|
||||
let events: Vec<(String, Option<String>, String, String)> = sqlx::query_as(
|
||||
"SELECT e.kind, e.detail, gs.name, e.created_at FROM server_events e JOIN game_servers gs ON gs.id = e.server_id
|
||||
WHERE e.uuid = ? AND e.kind IN ('join', 'death', 'advancement', 'kill') ORDER BY e.id DESC LIMIT 8",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
let unlocked: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT a.title, ua.unlocked_at FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id WHERE ua.user_uuid = ? ORDER BY ua.unlocked_at DESC LIMIT 8",
|
||||
)
|
||||
.bind(uuid)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
let mut recent_activity: Vec<scopenet_shared::ProfileActivity> = events
|
||||
.into_iter()
|
||||
.map(|(kind, detail, server, at)| {
|
||||
let detail = detail.unwrap_or_default();
|
||||
let text = match kind.as_str() {
|
||||
"join" => format!("Joined {server}"),
|
||||
"death" => format!("Died on {server}"),
|
||||
"kill" => format!("Defeated {}", if detail.is_empty() { "a foe".into() } else { detail.chars().take(40).collect::<String>() }),
|
||||
_ => format!("Earned {}", if detail.is_empty() { "an advancement".into() } else { detail.chars().take(60).collect::<String>() }),
|
||||
};
|
||||
scopenet_shared::ProfileActivity { kind, text, at }
|
||||
})
|
||||
.chain(unlocked.into_iter().map(|(title, at)| scopenet_shared::ProfileActivity { kind: "achievement".into(), text: format!("Unlocked {title}"), at }))
|
||||
.collect();
|
||||
recent_activity.sort_by(|a, b| b.at.cmp(&a.at));
|
||||
recent_activity.truncate(8);
|
||||
|
||||
let (relationship, mutual_friends) = match viewer {
|
||||
Some(v) if v == uuid => ("self".to_string(), vec![]),
|
||||
Some(v) => {
|
||||
let row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT status, action_uuid FROM friendships WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)",
|
||||
)
|
||||
.bind(v).bind(uuid).bind(uuid).bind(v)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
let relationship = match row {
|
||||
Some((s, _)) if s == "accepted" => "accepted",
|
||||
Some((_, action)) if action == v => "pending_outgoing",
|
||||
Some(_) => "pending_incoming",
|
||||
None => "none",
|
||||
}
|
||||
.to_string();
|
||||
let mutual: Vec<(String, String)> = sqlx::query_as(
|
||||
"WITH mine AS (SELECT CASE WHEN user_uuid = ?1 THEN friend_uuid ELSE user_uuid END AS f FROM friendships WHERE status = 'accepted' AND (user_uuid = ?1 OR friend_uuid = ?1)),
|
||||
theirs AS (SELECT CASE WHEN user_uuid = ?2 THEN friend_uuid ELSE user_uuid END AS f FROM friendships WHERE status = 'accepted' AND (user_uuid = ?2 OR friend_uuid = ?2))
|
||||
SELECT u.uuid, u.username FROM users u WHERE u.uuid IN (SELECT f FROM mine INTERSECT SELECT f FROM theirs) ORDER BY u.username COLLATE NOCASE LIMIT 12",
|
||||
)
|
||||
.bind(v)
|
||||
.bind(uuid)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
(relationship, mutual.into_iter().map(|(uuid, username)| scopenet_shared::MutualFriend { uuid, username }).collect())
|
||||
}
|
||||
None => ("none".to_string(), vec![]),
|
||||
};
|
||||
|
||||
let accent_color: Option<String> = sqlx::query_scalar("SELECT accent_color FROM user_profiles WHERE uuid = ?").bind(uuid).fetch_optional(db).await?.flatten();
|
||||
let discord_linked: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM account_connections ac JOIN users u ON u.id = ac.user_id WHERE u.uuid = ? AND ac.provider = 'discord')")
|
||||
.bind(uuid)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
Ok(ProfileExtras {
|
||||
online,
|
||||
last_seen,
|
||||
guild: guild.map(|(id, name, tag, role)| scopenet_shared::ProfileGuild { id, name, tag, role }),
|
||||
rank: rank.map(|(primary, display, prefix, server_name)| scopenet_shared::ProfileRank { display: if display.is_empty() { primary } else { display }, prefix, server_name }),
|
||||
favorite_server,
|
||||
wealth,
|
||||
recent_activity,
|
||||
mutual_friends,
|
||||
relationship,
|
||||
accent_color,
|
||||
discord_linked,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProfilePayload {
|
||||
pub accent_color: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub custom_badge: Option<String>,
|
||||
@@ -602,6 +751,7 @@ pub async fn update_my_profile(
|
||||
Json(payload): Json<UpdateProfilePayload>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let payload_accent = payload.accent_color.clone();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO user_profiles (uuid, bio, banner_url, custom_badge, featured_achievement_id, updated_at)
|
||||
@@ -626,6 +776,15 @@ pub async fn update_my_profile(
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
if let Some(color) = payload_accent {
|
||||
let color = color.trim().to_lowercase();
|
||||
let valid = color.is_empty() || (color.len() == 7 && color.starts_with('#') && color[1..].chars().all(|c| c.is_ascii_hexdigit()));
|
||||
if !valid {
|
||||
return Err(AppError::bad_request("accent colour must look like #8b6cff"));
|
||||
}
|
||||
sqlx::query("UPDATE user_profiles SET accent_color = ? WHERE uuid = ?").bind(if color.is_empty() { None } else { Some(color) }).bind(&auth.uuid).execute(&state.db).await?;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user