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

+4
View File
@@ -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 '';
+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 })))
}
+48
View File
@@ -0,0 +1,48 @@
//! Player profiles: presence, guild, rank, wealth, activity, mutual friends and cosmetics.
mod common;
use common::*;
#[tokio::test]
async fn profile_shows_the_whole_player() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
for n in ["Alex", "Steve", "Mia"] {
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123"}))).await;
}
let (alex, steve) = (t.login("Alex", "password123").await, t.login("Steve", "password123").await);
let (au, su, mu) = (t.uuid("Alex").await, t.uuid("Steve").await, t.uuid("Mia").await);
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await;
let sid = srv["server"]["id"].as_i64().unwrap();
// Alex and Steve both know Mia, but not each other.
for (a, b) in [(&au, &mu), (&su, &mu)] {
sqlx::query("INSERT INTO friendships (user_uuid, friend_uuid, status, action_uuid, created_at, updated_at) VALUES (?, ?, 'accepted', ?, 'x', 'x')").bind(a).bind(b).bind(a).execute(&t.db).await.unwrap();
}
t.call("POST", "/api/v1/guilds", Some(&steve), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 4321.5, 'x')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO player_ranks (server_id, uuid, primary_group, display, prefix, groups, permissions, weight, updated_at) VALUES (?, ?, 'vip', 'VIP', '[VIP]', '[\"vip\"]', '[]', 10, 'x')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO player_stats (server_id, uuid, name, playtime_secs, first_seen, last_seen) VALUES (?, ?, 'Steve', 7200, 'x', '2026-01-01T00:00:00Z')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO server_events (server_id, uuid, name, kind, detail, created_at) VALUES (?, ?, 'Steve', 'join', NULL, '2026-01-01T00:00:00Z'), (?, ?, 'Steve', 'chat', 'secret', '2026-01-01T00:00:01Z')").bind(sid).bind(&su).bind(sid).bind(&su).execute(&t.db).await.unwrap();
let (s, p) = t.call("GET", &format!("/api/v1/profiles/{su}"), Some(&alex), None).await;
assert_eq!(s, StatusCode::OK, "{p}");
assert_eq!(p["relationship"], "none");
assert_eq!(p["mutual_friends"][0]["username"], "Mia");
assert_eq!((p["guild"]["tag"].as_str(), p["guild"]["role"].as_str()), (Some("IRON"), Some("leader")));
assert_eq!((p["rank"]["display"].as_str(), p["rank"]["server_name"].as_str()), (Some("VIP"), Some("SMP")));
assert_eq!(p["favorite_server"], "SMP");
assert_eq!(p["wealth"], 4321.5);
let activity = p["recent_activity"].as_array().unwrap();
assert!(activity.iter().any(|a| a["text"] == "Joined SMP"));
assert!(activity.iter().all(|a| !a["text"].as_str().unwrap().contains("secret")), "chat must not leak");
// Viewing yourself, and cosmetics.
let (_, me) = t.call("GET", &format!("/api/v1/profiles/{au}"), Some(&alex), None).await;
assert_eq!(me["relationship"], "self");
let (s, _) = t.call("PUT", "/api/v1/profiles/me", Some(&alex), Some(json!({"bio": "hi", "accent_color": "#22D3EE"}))).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(t.call("GET", &format!("/api/v1/profiles/{au}"), Some(&alex), None).await.1["accent_color"], "#22d3ee");
let (s, _) = t.call("PUT", "/api/v1/profiles/me", Some(&alex), Some(json!({"bio": "hi", "accent_color": "red; background:url(x)"}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}