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:
Claude committed 2026-09-30 18:29:07 +00:00
1 parent 745c2178ee
commit 9dc7f12fb0
13 files changed
+655 -13

No files matched your search

+159
View File
@@ -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 })))
}