Files
SCOPENET-MC/panel/server/src/routes/leveling.rs
T

377 lines
13 KiB
Rust

//! Leveling system (Global Level & Server-Specific Levels), rewards, and progression.
use crate::auth::{AdminUser, AuthUser};
use crate::error::AppResult;
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::Json;
use scopenet_shared::{ServerLevelInfo, UserLevelInfo};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use crate::progression::{level_from_xp, xp_for_level};
/// Award each configured entitlement once in the transaction which earned it.
/// Achievement XP is credited automatically via the `achievement_xp` DB trigger;
/// this function only processes level-up rewards (titles, badges, item entitlements).
pub async fn grant_rewards(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, now: &str) -> AppResult<()> {
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
.bind(uuid).fetch_optional(&mut **tx).await?.unwrap_or(0);
let level = level_from_xp(xp).0;
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?")
.bind(level).bind(uuid).execute(&mut **tx).await?;
let rewards: Vec<(i64, String, Option<i64>, String, String, String)> = sqlx::query_as(
"SELECT r.id, r.level_type, r.server_id, r.reward_type, r.reward_name, r.reward_data FROM level_rewards r
WHERE (r.level_type = 'global' AND r.level_req <= ?)
OR (r.level_type = 'server' AND EXISTS (SELECT 1 FROM server_levels sl WHERE sl.uuid = ? AND sl.server_id = r.server_id AND sl.server_level >= r.level_req))
ORDER BY r.level_req, r.id")
.bind(level).bind(uuid).fetch_all(&mut **tx).await?;
for (id, scope, server_id, kind, name, data) in rewards {
let inserted = sqlx::query("INSERT OR IGNORE INTO granted_rewards(uuid, reward_id, granted_at) VALUES (?, ?, ?)")
.bind(uuid).bind(id).bind(now).execute(&mut **tx).await?.rows_affected();
if inserted == 0 { continue; }
match kind.as_str() {
"title" if scope == "global" => {
sqlx::query("UPDATE user_levels SET title = ? WHERE uuid = ?").bind(&name).bind(uuid).execute(&mut **tx).await?;
}
"title" => {
sqlx::query("UPDATE server_levels SET rank_name = ? WHERE server_id = ? AND uuid = ?")
.bind(&name).bind(server_id).bind(uuid).execute(&mut **tx).await?;
}
"badge" | "profile_badge" => {
let raw: String = sqlx::query_scalar("SELECT badges FROM user_levels WHERE uuid = ?")
.bind(uuid).fetch_optional(&mut **tx).await?.unwrap_or_else(|| "[]".into());
let mut badges: Vec<String> = serde_json::from_str(&raw).unwrap_or_default();
let data: Value = serde_json::from_str(&data).unwrap_or_default();
let badge = data["badge"].as_str().unwrap_or(&name).to_string();
if !badges.contains(&badge) { badges.push(badge); }
sqlx::query("INSERT INTO user_levels(uuid, badges, updated_at) VALUES (?, ?, ?) ON CONFLICT(uuid) DO UPDATE SET badges = excluded.badges")
.bind(uuid).bind(serde_json::to_string(&badges)?).bind(now).execute(&mut **tx).await?;
}
_ => {} // Item/cosmetic entitlements are recorded in granted_rewards.
}
}
Ok(())
}
#[derive(Serialize, Deserialize)]
pub struct RewardRow {
pub id: i64,
pub level_type: String,
pub server_id: Option<i64>,
pub level_req: i64,
pub reward_type: String,
pub reward_name: String,
pub reward_data: Value,
pub created_at: String,
}
/// Get current user's global and server levels.
pub async fn get_my_levels(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<UserLevelInfo>> {
get_user_levels_by_uuid(&state, &auth.uuid).await.map(Json)
}
/// Public / Profile lookup for player levels.
pub async fn get_player_levels(
Path(uuid): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<UserLevelInfo>> {
get_user_levels_by_uuid(&state, &uuid).await.map(Json)
}
pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<UserLevelInfo> {
let row: Option<(i64, i64, Option<String>, String)> = sqlx::query_as(
"SELECT global_xp, global_level, title, badges FROM user_levels WHERE uuid = ?",
)
.bind(uuid)
.fetch_optional(&state.db)
.await?;
let (global_xp, _db_lvl, title, badges_json) = row.unwrap_or((0, 1, None, "[]".into()));
let (global_level, current_level_xp, next_level_xp, progress_pct) = level_from_xp(global_xp);
let badges: Vec<String> = serde_json::from_str(&badges_json).unwrap_or_default();
// Query server levels for this user
let server_rows: Vec<(i64, String, i64, i64, Option<String>)> = sqlx::query_as(
"SELECT sl.server_id, gs.name, sl.server_xp, sl.server_level, sl.rank_name
FROM server_levels sl
JOIN game_servers gs ON gs.id = sl.server_id
WHERE sl.uuid = ?
ORDER BY sl.server_xp DESC",
)
.bind(uuid)
.fetch_all(&state.db)
.await?;
let server_levels = server_rows
.into_iter()
.map(|(s_id, s_name, s_xp, _s_lvl, r_name)| {
let (sl, cur, next, pct) = level_from_xp(s_xp);
ServerLevelInfo {
server_id: s_id,
server_name: s_name,
server_xp: s_xp,
server_level: sl,
current_level_xp: cur,
next_level_xp: next,
progress_pct: pct,
rank_name: r_name,
}
})
.collect();
// Query rewards
let rewards_rows: Vec<(i64, i64, String, String, String)> = sqlx::query_as(
"SELECT id, level_req, reward_type, reward_name, reward_data
FROM level_rewards
ORDER BY level_req ASC",
)
.fetch_all(&state.db)
.await
.unwrap_or_default();
let rewards = rewards_rows
.into_iter()
.map(|(id, req, rtype, rname, rdata)| {
let data: Value = serde_json::from_str(&rdata).unwrap_or_default();
let desc = data.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
let icon = data.get("icon").and_then(|v| v.as_str()).map(|s| s.to_string());
scopenet_shared::LevelReward {
id,
level: req,
reward_type: rtype,
reward_value: rname,
description: desc,
icon,
server_id: None,
}
})
.collect();
let rank_opt: Option<i64> = sqlx::query_scalar(
"SELECT COUNT(*) + 1 FROM user_levels WHERE global_xp > ?",
)
.bind(global_xp)
.fetch_optional(&state.db)
.await
.unwrap_or(Some(1));
Ok(UserLevelInfo {
uuid: uuid.to_string(),
global_xp,
current_xp: global_xp,
global_level,
level: global_level,
current_level_xp,
next_level_xp,
progress_pct,
title,
badges,
server_levels,
rank: rank_opt.map(|r| r as usize),
rewards,
})
}
/// Global leveling leaderboard.
pub async fn get_level_leaderboard(
State(state): State<AppState>,
) -> AppResult<Json<Vec<Value>>> {
let rows: Vec<(String, String, i64, i64, Option<String>)> = sqlx::query_as(
"SELECT u.uuid, u.username, ul.global_xp, ul.global_level, ul.title
FROM user_levels ul
JOIN users u ON u.uuid = ul.uuid
ORDER BY ul.global_xp DESC
LIMIT 50",
)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.enumerate()
.map(|(rank, (uuid, username, xp, _, title))| {
let (level, cur, next, pct) = level_from_xp(xp);
serde_json::json!({
"rank": rank + 1,
"uuid": uuid,
"username": username,
"global_level": level,
"global_xp": xp,
"current_level_xp": cur,
"next_level_xp": next,
"progress_pct": pct,
"title": title
})
})
.collect();
Ok(Json(list))
}
/// Get available rewards.
pub async fn list_rewards(
State(state): State<AppState>,
) -> AppResult<Json<Vec<Value>>> {
let rows: Vec<(i64, String, Option<i64>, i64, String, String, String, String)> = sqlx::query_as(
"SELECT id, level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at
FROM level_rewards
ORDER BY level_req ASC",
)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(|(id, ltype, sid, req, rtype, name, data, created)| {
let parsed_data: Value = serde_json::from_str(&data).unwrap_or_default();
let desc = parsed_data.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let icon = parsed_data.get("icon")
.and_then(|v| v.as_str())
.unwrap_or(match rtype.as_str() {
"title" => "👑",
"profile_badge" | "badge" => "⭐",
"cosmetic" => "✨",
"item" => "🗡️",
_ => "🎁",
})
.to_string();
serde_json::json!({
"id": id,
"level": req,
"level_req": req,
"level_type": ltype,
"server_id": sid,
"reward_type": rtype,
"reward_value": name,
"reward_name": name,
"description": if desc.is_empty() { format!("Granted at Level {req}") } else { desc },
"icon": icon,
"reward_data": parsed_data,
"created_at": created
})
})
.collect();
Ok(Json(list))
}
#[derive(Deserialize)]
pub struct RewardPayload {
pub id: Option<i64>,
pub level_type: Option<String>,
pub server_id: Option<i64>,
#[serde(alias = "level")]
pub level_req: Option<i64>,
pub reward_type: String,
#[serde(alias = "reward_value")]
pub reward_name: Option<String>,
pub description: Option<String>,
pub icon: Option<String>,
pub reward_data: Option<Value>,
}
/// Admin create reward.
pub async fn admin_create_reward(
_admin: AdminUser,
State(state): State<AppState>,
Json(payload): Json<RewardPayload>,
) -> AppResult<Json<Value>> {
let now = chrono::Utc::now().to_rfc3339();
let level_req = payload.level_req.unwrap_or(2);
let reward_name = payload.reward_name.unwrap_or_else(|| "Reward".into());
let level_type = payload.level_type.unwrap_or_else(|| "global".into());
let mut data_obj = match payload.reward_data {
Some(Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
if let Some(desc) = payload.description {
data_obj.insert("description".into(), Value::String(desc));
}
if let Some(ico) = payload.icon {
data_obj.insert("icon".into(), Value::String(ico));
}
data_obj.insert("title".into(), Value::String(reward_name.clone()));
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
let id: i64 = sqlx::query_scalar(
"INSERT INTO level_rewards (level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(level_type)
.bind(payload.server_id)
.bind(level_req)
.bind(payload.reward_type)
.bind(reward_name)
.bind(data_str)
.bind(&now)
.fetch_one(&state.db)
.await?;
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
}
/// Admin update reward.
pub async fn admin_update_reward(
_admin: AdminUser,
Path(id): Path<i64>,
State(state): State<AppState>,
Json(payload): Json<RewardPayload>,
) -> AppResult<Json<Value>> {
let level_req = payload.level_req.unwrap_or(2);
let reward_name = payload.reward_name.unwrap_or_else(|| "Reward".into());
let mut data_obj = match payload.reward_data {
Some(Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
if let Some(desc) = payload.description {
data_obj.insert("description".into(), Value::String(desc));
}
if let Some(ico) = payload.icon {
data_obj.insert("icon".into(), Value::String(ico));
}
data_obj.insert("title".into(), Value::String(reward_name.clone()));
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
sqlx::query(
"UPDATE level_rewards SET
level_req = ?,
reward_type = ?,
reward_name = ?,
reward_data = ?
WHERE id = ?",
)
.bind(level_req)
.bind(payload.reward_type)
.bind(reward_name)
.bind(data_str)
.bind(id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
}
/// Admin delete reward.
pub async fn admin_delete_reward(
_admin: AdminUser,
Path(id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM level_rewards WHERE id = ?")
.bind(id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}