feat: add global & server leveling, quests, achievements, guilds land-claiming, social & toggleable configs
This commit is contained in:
1 parent
4b79f69c20
commit
b4cef85b1f
40 files changed
+10261
-27
No files matched your search
@@ -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 })))
|
||||
}
|
||||
Reference in new issue
Block a user