Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
377 lines
13 KiB
Rust
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 })))
|
|
}
|