//! 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, 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 = 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, 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, ) -> AppResult> { 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, State(state): State, ) -> AppResult> { get_user_levels_by_uuid(&state, &uuid).await.map(Json) } pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult { let row: Option<(i64, i64, Option, 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 = serde_json::from_str(&badges_json).unwrap_or_default(); // Query server levels for this user let server_rows: Vec<(i64, String, i64, i64, Option)> = 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 = 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, ) -> AppResult>> { let rows: Vec<(String, String, i64, i64, Option)> = 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, ) -> AppResult>> { let rows: Vec<(i64, String, Option, 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, pub level_type: Option, pub server_id: Option, #[serde(alias = "level")] pub level_req: Option, pub reward_type: String, #[serde(alias = "reward_value")] pub reward_name: Option, pub description: Option, pub icon: Option, pub reward_data: Option, } /// Admin create reward. pub async fn admin_create_reward( _admin: AdminUser, State(state): State, Json(payload): Json, ) -> AppResult> { 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, State(state): State, Json(payload): Json, ) -> AppResult> { 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, State(state): State, ) -> AppResult> { sqlx::query("DELETE FROM level_rewards WHERE id = ?") .bind(id) .execute(&state.db) .await?; Ok(Json(serde_json::json!({ "ok": true }))) }