diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index 7c1a3ab..df38652 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -478,3 +478,45 @@ pub fn show_window(app: AppHandle) { w.set_focus().ok(); } } + +// ---- stats & leaderboards ---- + +#[tauri::command] +pub async fn get_player_stats(state: State<'_, AppState>) -> Res { + let req = account_api(&state, reqwest::Method::GET, "/account/stats").await?; + let resp = req.send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load player stats".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn get_leaderboard( + state: State<'_, AppState>, + server_id: Option, + sort: Option, +) -> Res { + let panel = state.panel_url().ok_or("no panel configured")?; + let sort_query = sort.map(|s| format!("?sort={s}")).unwrap_or_default(); + let url = if let Some(id) = server_id { + format!("{panel}/api/v1/servers/{id}/leaderboard{sort_query}") + } else { + format!("{panel}/api/v1/leaderboard{sort_query}") + }; + let resp = state.http.get(&url).send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load leaderboard".into()); + } + resp.json().await.map_err(err) +} + +#[tauri::command] +pub async fn get_public_servers(state: State<'_, AppState>) -> Res { + let panel = state.panel_url().ok_or("no panel configured")?; + let resp = state.http.get(format!("{panel}/api/v1/servers/public")).send().await.map_err(err)?; + if !resp.status().is_success() { + return Err("unable to load servers".into()); + } + resp.json().await.map_err(err) +} diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index 273bfde..8f10f5a 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -81,6 +81,9 @@ pub fn run() { commands::check_update, commands::install_update, commands::show_window, + commands::get_player_stats, + commands::get_leaderboard, + commands::get_public_servers, ]) .run(tauri::generate_context!()) .expect("error while running the launcher"); diff --git a/launcher/src/App.svelte b/launcher/src/App.svelte index 322f747..65f12ab 100644 --- a/launcher/src/App.svelte +++ b/launcher/src/App.svelte @@ -13,6 +13,7 @@ import Login from './pages/Login.svelte'; import Home from './pages/Home.svelte'; import Settings from './pages/Settings.svelte'; + import Stats from './pages/Stats.svelte'; import { app, init } from './lib/store.svelte'; import { errorText } from './lib/tauri'; @@ -40,7 +41,13 @@
- {#if app.view === 'settings'}{:else}{/if} + {#if app.view === 'settings'} + + {:else if app.view === 'stats'} + + {:else} + + {/if}
{/if} diff --git a/launcher/src/components/Avatar.svelte b/launcher/src/components/Avatar.svelte index ded9009..d81f822 100644 --- a/launcher/src/components/Avatar.svelte +++ b/launcher/src/components/Avatar.svelte @@ -2,11 +2,20 @@ import { app } from '../lib/store.svelte'; import type { Account } from '../lib/types'; - let { account, size = 2.2 }: { account: Account | null; size?: number } = $props(); + let { account = null, name = null, uuid = null, size = 2.2 }: { + account?: Account | null; + name?: string | null; + uuid?: string | null; + size?: number; + } = $props(); + let failed = $state(false); - const hue = $derived([...(account?.username ?? '?')].reduce((a, c) => a + c.charCodeAt(0), 0) % 360); - // Server accounts: head rendered by the panel from the player's skin. - const src = $derived(account?.kind === 'panel' && account.panel_url ? `${account.panel_url}/api/v1/avatar/${account.uuid}?size=64&v=${app.skinVersion}` : null); + const username = $derived(account?.username ?? name ?? '?'); + const playerUuid = $derived(account?.uuid ?? uuid ?? null); + const panel = $derived(account?.panel_url ?? app.panelUrl); + // Head rendered by the panel from the player's skin. + const src = $derived(panel && playerUuid ? `${panel}/api/v1/avatar/${playerUuid}?size=64&v=${app.skinVersion}` : null); + const hue = $derived([...username].reduce((a, c) => a + c.charCodeAt(0), 0) % 360); $effect(() => { void src; failed = false; @@ -16,7 +25,7 @@ {#if src && !failed} (failed = true)} /> {:else} - {account?.username.slice(0, 1).toUpperCase() ?? '?'} + {username.slice(0, 1).toUpperCase()} {/if} diff --git a/launcher/src/components/Rail.svelte b/launcher/src/components/Rail.svelte index 98443c7..e11c6c7 100644 --- a/launcher/src/components/Rail.svelte +++ b/launcher/src/components/Rail.svelte @@ -1,5 +1,5 @@ + +
+ + +
+ {#if activeTab === 'personal'} + {#if loadingPersonal} +
+ {:else if !me || me.kind === 'offline'} +
+ +

Server account required

+

Personal gameplay stats are synced when you play on servers with your SCOPENET server account.

+ +
+ {:else if !personalData || !personalData.total} +
+ +

No recorded stats yet

+

Hop into a server through the launcher to begin logging kills, blocks mined, and playtime!

+
+ {:else} + {@const t = personalData.total} + {@const kd = (t.player_kills / Math.max(1, t.deaths)).toFixed(2)} +
+ +
+
+ {personalData.username} + Active +
+
+ UUID: {personalData.uuid.slice(0, 14)}โ€ฆ + ยท + First played: {formatDate(t.first_seen)} + ยท + Last active: {formatDate(t.last_seen)} +
+
+
+ Total Playtime + {formatPlaytime(t.playtime_secs)} +
+
+ +
+
+
+ Playtime + +
+
{formatPlaytime(t.playtime_secs)}
+
Across all servers
+
+ +
+
+ Player Kills + +
+
{formatNumber(t.player_kills)}
+
PvP eliminations
+
+ +
+
+ Deaths + +
+
{formatNumber(t.deaths)}
+
K/D: {kd}
+
+ +
+
+ Mob Kills + +
+
{formatNumber(t.mob_kills)}
+
Monsters & animals
+
+ +
+
+ Blocks Mined + +
+
{formatNumber(t.blocks_broken)}
+
Broken blocks
+
+ +
+
+ Blocks Placed + +
+
{formatNumber(t.blocks_placed)}
+
Structures built
+
+ +
+
+ Server Joins + +
+
{formatNumber(t.joins)}
+
Sessions played
+
+ +
+
+ Chat Messages + +
+
{formatNumber(t.messages)}
+
Messages sent
+
+
+ + {#if personalData.servers.length} +
+

Server Activity

+
+ {#each personalData.servers as s} +
+
+ {s.server_name} + Last played {formatDate(s.last_seen)} +
+
+
+ Playtime + {formatPlaytime(s.playtime_secs)} +
+
+ Kills + {s.player_kills} +
+
+ Deaths + {s.deaths} +
+
+ Mined + {formatNumber(s.blocks_broken)} +
+
+
+ {/each} +
+
+ {/if} + {/if} + {:else} + +
+
+ Server: + +
+ +
+ {#each sortOptions as opt} + {@const Icon = opt.icon} + + {/each} +
+
+ + {#if loadingLeaderboard} +
+ {:else if !leaderboardData || leaderboardData.leaderboard.length === 0} +
+ +

No leaderboard entries yet

+

As players explore, mine, and battle on servers, the rankings will appear here automatically.

+
+ {:else} + + {#if top3.length > 0} +
+ {#if top3[1]} +
+ 2 + + {top3[1].name} + {statValue(top3[1], sortField)} +
+ {/if} + {#if top3[0]} +
+ ๐Ÿ‘‘ + 1 + + {top3[0].name} + {statValue(top3[0], sortField)} +
+ {/if} + {#if top3[2]} +
+ 3 + + {top3[2].name} + {statValue(top3[2], sortField)} +
+ {/if} +
+ {/if} + + +
+
+ # + Player + {sortOptions.find(o => o.id === sortField)?.label ?? 'Stat'} + Playtime + PvP Kills + Last Seen +
+
+ {#each leaderboardData.leaderboard as row, idx (row.uuid)} + {@const isMe = me?.uuid === row.uuid} +
+ + {#if idx === 0}๐Ÿฅ‡{:else if idx === 1}๐Ÿฅˆ{:else if idx === 2}๐Ÿฅ‰{:else}{idx + 1}{/if} + +
+ + {row.name} + {#if isMe}You{/if} +
+ {statValue(row, sortField)} + {formatPlaytime(row.playtime_secs)} + {row.player_kills} + {formatDate(row.last_seen)} +
+ {/each} +
+
+ {/if} + {/if} +
+
+ + diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index 217652c..eedbc0f 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -21,11 +21,15 @@ pub fn api(state: &AppState) -> Router { .route("/auth/register", post(public::register)) .route("/auth/me", get(public::me)) .route("/account/profile", get(account::profile)) + .route("/account/stats", get(servers::account_player_stats)) .route("/account/username", axum::routing::put(account::set_username)) .route("/account/skin", post(account::upload_skin).delete(account::delete_skin)) .route("/account/skin/model", axum::routing::put(account::set_model)) .route("/account/cape", axum::routing::put(account::set_cape)) .route("/avatar/{id}", get(account::avatar)) + .route("/servers/public", get(servers::public_servers)) + .route("/servers/{id}/leaderboard", get(servers::public_server_leaderboard)) + .route("/leaderboard", get(servers::global_leaderboard)) .route("/launcher/authlib-injector.json", get(account::authlib_index)) .route("/launcher/authlib-injector.jar", get(account::authlib_jar)) .layer(DefaultBodyLimit::max(4 * 1024 * 1024)); diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index b474288..de2d6d7 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -2,7 +2,7 @@ //! report to these endpoints with a per-server token, and admins manage the //! servers (and read the stats they collect) from the panel. -use crate::auth::{self, AdminUser, UserRow}; +use crate::auth::{self, AdminUser, AuthUser, UserRow}; use crate::error::{AppError, AppResult}; use crate::net; use crate::state::AppState; @@ -640,30 +640,30 @@ pub async fn remove(_: AdminUser, State(state): State, Path(id): Path< #[derive(Serialize, sqlx::FromRow)] pub struct StatRow { - server_id: i64, - uuid: String, - name: String, - playtime_secs: i64, - joins: i64, - deaths: i64, - player_kills: i64, - mob_kills: i64, - blocks_broken: i64, - blocks_placed: i64, - messages: i64, - first_seen: String, - last_seen: String, + pub server_id: i64, + pub uuid: String, + pub name: String, + pub playtime_secs: i64, + pub joins: i64, + pub deaths: i64, + pub player_kills: i64, + pub mob_kills: i64, + pub blocks_broken: i64, + pub blocks_placed: i64, + pub messages: i64, + pub first_seen: String, + pub last_seen: String, } #[derive(Serialize, sqlx::FromRow)] pub struct EventRow { - id: i64, - server_id: i64, - uuid: Option, - name: Option, - kind: String, - detail: Option, - created_at: String, + pub id: i64, + pub server_id: i64, + pub uuid: Option, + pub name: Option, + pub kind: String, + pub detail: Option, + pub created_at: String, } #[derive(Serialize, sqlx::FromRow)] @@ -754,8 +754,178 @@ pub async fn user_activity(_: AdminUser, State(state): State, Path(use pub struct StatRowNamed { #[sqlx(flatten)] #[serde(flatten)] - stats: StatRow, - server_name: String, + pub stats: StatRow, + pub server_name: String, +} + +#[derive(Deserialize)] +pub struct LeaderboardQuery { + #[serde(default = "default_sort")] + pub sort: String, + #[serde(default = "default_limit")] + pub limit: i64, +} + +fn default_limit() -> i64 { + 50 +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct AggregatedStatRow { + pub uuid: String, + pub name: String, + pub playtime_secs: i64, + pub joins: i64, + pub deaths: i64, + pub player_kills: i64, + pub mob_kills: i64, + pub blocks_broken: i64, + pub blocks_placed: i64, + pub messages: i64, + pub first_seen: String, + pub last_seen: String, +} + +/// Public list of servers with status and player counts. +pub async fn public_servers(State(state): State) -> AppResult> { + let servers: Vec = sqlx::query_as("SELECT * FROM game_servers ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?; + let list: Vec = servers.into_iter().map(|s| { + let is_online = s.is_online(); + json!({ + "id": s.id, + "name": s.name, + "online": is_online, + "players_online": if is_online { s.online_count } else { 0 }, + "players_max": s.max_players, + "software": s.software, + "mc_version": s.mc_version, + "tps": s.tps, + "last_seen": s.last_seen, + }) + }).collect(); + Ok(Json(json!({ "servers": list }))) +} + +/// Public server-specific leaderboard. +pub async fn public_server_leaderboard( + State(state): State, + Path(id): Path, + Query(q): Query, +) -> AppResult> { + let server = get_server(&state, id).await?; + let sort = match q.sort.as_str() { + "joins" | "deaths" | "player_kills" | "mob_kills" | "blocks_broken" | "blocks_placed" | "messages" | "last_seen" => q.sort.as_str(), + _ => "playtime_secs", + }; + let limit = q.limit.clamp(1, 100); + let leaderboard: Vec = + sqlx::query_as(&format!("SELECT * FROM player_stats WHERE server_id = ? ORDER BY {sort} DESC LIMIT {limit}")) + .bind(id) + .fetch_all(&state.db) + .await?; + let (players, playtime): (i64, Option) = + sqlx::query_as("SELECT COUNT(*), SUM(playtime_secs) FROM player_stats WHERE server_id = ?").bind(id).fetch_one(&state.db).await?; + Ok(Json(json!({ + "server": { + "id": server.id, + "name": server.name, + "online": server.is_online(), + "players_online": server.online_count, + "players_max": server.max_players, + }, + "leaderboard": leaderboard, + "totals": { "players": players, "playtime_secs": playtime.unwrap_or(0) }, + "sort": sort, + }))) +} + +/// Public global leaderboard aggregated across all servers. +pub async fn global_leaderboard( + State(state): State, + Query(q): Query, +) -> AppResult> { + let sort = match q.sort.as_str() { + "joins" | "deaths" | "player_kills" | "mob_kills" | "blocks_broken" | "blocks_placed" | "messages" | "last_seen" => q.sort.as_str(), + _ => "playtime_secs", + }; + let limit = q.limit.clamp(1, 100); + let leaderboard: Vec = sqlx::query_as(&format!( + "SELECT uuid, name, + SUM(playtime_secs) as playtime_secs, + SUM(joins) as joins, + SUM(deaths) as deaths, + SUM(player_kills) as player_kills, + SUM(mob_kills) as mob_kills, + SUM(blocks_broken) as blocks_broken, + SUM(blocks_placed) as blocks_placed, + SUM(messages) as messages, + MIN(first_seen) as first_seen, + MAX(last_seen) as last_seen + FROM player_stats + GROUP BY uuid + ORDER BY {sort} DESC + LIMIT {limit}" + )) + .fetch_all(&state.db) + .await?; + + let (players, playtime): (i64, Option) = + sqlx::query_as("SELECT COUNT(DISTINCT uuid), SUM(playtime_secs) FROM player_stats") + .fetch_one(&state.db) + .await?; + + Ok(Json(json!({ + "leaderboard": leaderboard, + "totals": { "players": players, "playtime_secs": playtime.unwrap_or(0) }, + "sort": sort, + }))) +} + +/// Player's personal stats across servers (authenticated endpoint for launcher). +pub async fn account_player_stats( + State(state): State, + AuthUser(user): AuthUser, +) -> AppResult> { + let rows: Vec = sqlx::query_as( + "SELECT s.*, g.name AS server_name FROM player_stats s JOIN game_servers g ON g.id = s.server_id WHERE s.uuid = ? ORDER BY s.last_seen DESC", + ) + .bind(&user.uuid) + .fetch_all(&state.db) + .await?; + + let total: Option = sqlx::query_as( + "SELECT uuid, name, + SUM(playtime_secs) as playtime_secs, + SUM(joins) as joins, + SUM(deaths) as deaths, + SUM(player_kills) as player_kills, + SUM(mob_kills) as mob_kills, + SUM(blocks_broken) as blocks_broken, + SUM(blocks_placed) as blocks_placed, + SUM(messages) as messages, + MIN(first_seen) as first_seen, + MAX(last_seen) as last_seen + FROM player_stats + WHERE uuid = ? + GROUP BY uuid" + ) + .bind(&user.uuid) + .fetch_optional(&state.db) + .await?; + + let events: Vec = + sqlx::query_as("SELECT * FROM server_events WHERE uuid = ? ORDER BY id DESC LIMIT 20") + .bind(&user.uuid) + .fetch_all(&state.db) + .await?; + + Ok(Json(json!({ + "username": user.username, + "uuid": user.uuid, + "total": total, + "servers": rows, + "events": events, + }))) } /// Live summary for the dashboard.