feat: add global & server leveling, quests, achievements, guilds land-claiming, social & toggleable configs

This commit is contained in:
SCOPEDD committed 2026-09-29 03:24:27 -04:00
1 parent 4b79f69c20
commit b4cef85b1f
40 files changed
+10261 -27

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
//! 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;
/// XP required to reach level L.
pub fn xp_for_level(lvl: i64) -> i64 {
if lvl <= 1 {
return 0;
}
((lvl as f64).powf(1.5) * 100.0).round() as i64
}
/// Computes (level, current_level_xp, next_level_span, progress_pct) from total XP.
pub fn level_from_xp(xp: i64) -> (i64, i64, i64, f64) {
if xp <= 0 {
return (1, 0, 100, 0.0);
}
let mut lvl = 1;
while xp >= xp_for_level(lvl + 1) {
lvl += 1;
}
let curr_req = xp_for_level(lvl);
let next_req = xp_for_level(lvl + 1);
let span = (next_req - curr_req).max(1);
let progress_in_lvl = (xp - curr_req).max(0);
let pct = (progress_in_lvl as f64 / span as f64 * 100.0).clamp(0.0, 100.0);
(lvl, progress_in_lvl, span, pct)
}
#[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();
Ok(UserLevelInfo {
uuid: uuid.to_string(),
global_xp,
global_level,
current_level_xp,
next_level_xp,
progress_pct,
title,
badges,
server_levels,
})
}
/// 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();
serde_json::json!({
"id": id,
"level_type": ltype,
"server_id": sid,
"level_req": req,
"reward_type": rtype,
"reward_name": name,
"reward_data": parsed_data,
"created_at": created
})
})
.collect();
Ok(Json(list))
}
#[derive(Deserialize)]
pub struct CreateRewardPayload {
pub level_type: String,
pub server_id: Option<i64>,
pub level_req: i64,
pub reward_type: String,
pub reward_name: String,
pub reward_data: Option<Value>,
}
/// Admin create reward.
pub async fn admin_create_reward(
_admin: AdminUser,
State(state): State<AppState>,
Json(payload): Json<CreateRewardPayload>,
) -> AppResult<Json<Value>> {
let now = chrono::Utc::now().to_rfc3339();
let data_str = serde_json::to_string(&payload.reward_data.unwrap_or_default()).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(payload.level_type)
.bind(payload.server_id)
.bind(payload.level_req)
.bind(payload.reward_type)
.bind(payload.reward_name)
.bind(data_str)
.bind(&now)
.fetch_one(&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 })))
}