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

+296
View File
@@ -0,0 +1,296 @@
//! Minecraft-style Achievements system.
use crate::auth::{AdminUser, AuthUser};
use crate::error::AppResult;
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::Json;
use scopenet_shared::Achievement;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Get all achievements for authenticated user with their unlock status.
pub async fn get_my_achievements(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<Achievement>>> {
get_achievements_for_user(&state, &auth.uuid, false).await.map(Json)
}
/// Get public unlocked achievements for a specific player profile.
pub async fn get_player_achievements(
Path(uuid): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<Achievement>>> {
get_achievements_for_user(&state, &uuid, true).await.map(Json)
}
pub async fn get_achievements_for_user(
state: &AppState,
uuid: &str,
unlocked_only: bool,
) -> AppResult<Vec<Achievement>> {
let rows: Vec<(
String,
String,
String,
String,
String,
String,
String,
String,
i64,
bool,
Option<String>,
)> = sqlx::query_as(
"SELECT a.id, a.title, a.description, a.category, a.icon_frame, a.icon_item,
a.icon_bg, a.icon_border, a.xp_reward, a.secret, ua.unlocked_at
FROM achievements a
LEFT JOIN user_achievements ua ON ua.achievement_id = a.id AND ua.user_uuid = ?
ORDER BY a.category ASC, a.xp_reward ASC",
)
.bind(uuid)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.filter_map(
|(
id,
title,
desc,
cat,
frame,
item,
bg,
border,
xp,
secret,
unlocked_at,
)| {
let unlocked = unlocked_at.is_some();
if unlocked_only && !unlocked {
return None;
}
Some(Achievement {
id,
title,
description: desc,
category: cat,
icon_frame: frame,
icon_item: item,
icon_bg: bg,
icon_border: border,
xp_reward: xp,
secret,
unlocked,
unlocked_at,
})
},
)
.collect();
Ok(list)
}
// ---------------------------------------------------------------------------
// Admin Achievements API
// ---------------------------------------------------------------------------
#[derive(Serialize)]
pub struct AdminAchievementRow {
#[serde(flatten)]
pub achievement: Achievement,
pub requirement_type: String,
pub requirement_key: String,
pub requirement_value: i64,
pub total_unlocked: i64,
}
/// Admin list all achievements.
pub async fn admin_list_achievements(
_admin: AdminUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<AdminAchievementRow>>> {
let rows: Vec<(
String,
String,
String,
String,
String,
String,
String,
String,
String,
String,
i64,
i64,
bool,
i64,
)> = sqlx::query_as(
"SELECT a.id, a.title, a.description, a.category, a.icon_frame, a.icon_item,
a.icon_bg, a.icon_border, a.requirement_type, a.requirement_key,
a.requirement_value, a.xp_reward, a.secret,
COUNT(ua.user_uuid) as total_unlocked
FROM achievements a
LEFT JOIN user_achievements ua ON ua.achievement_id = a.id
GROUP BY a.id
ORDER BY a.category ASC, a.created_at DESC",
)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(
|(
id,
title,
desc,
cat,
frame,
item,
bg,
border,
req_type,
req_key,
req_val,
xp,
secret,
total_unlocked,
)| {
AdminAchievementRow {
achievement: Achievement {
id,
title,
description: desc,
category: cat,
icon_frame: frame,
icon_item: item,
icon_bg: bg,
icon_border: border,
xp_reward: xp,
secret,
unlocked: false,
unlocked_at: None,
},
requirement_type: req_type,
requirement_key: req_key,
requirement_value: req_val,
total_unlocked,
}
},
)
.collect();
Ok(Json(list))
}
#[derive(Deserialize)]
pub struct AchievementPayload {
pub id: Option<String>,
pub title: String,
pub description: String,
pub category: String,
pub icon_frame: String,
pub icon_item: String,
pub icon_bg: String,
pub icon_border: String,
pub requirement_type: String,
pub requirement_key: String,
pub requirement_value: i64,
pub xp_reward: i64,
pub secret: Option<bool>,
}
/// Admin create achievement.
pub async fn admin_create_achievement(
_admin: AdminUser,
State(state): State<AppState>,
Json(payload): Json<AchievementPayload>,
) -> AppResult<Json<Value>> {
let id = payload
.id
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| format!("ach_{}", uuid::Uuid::new_v4().simple()));
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO achievements (id, title, description, category, icon_frame, icon_item, icon_bg, icon_border, requirement_type, requirement_key, requirement_value, xp_reward, secret, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(payload.title)
.bind(payload.description)
.bind(payload.category)
.bind(payload.icon_frame)
.bind(payload.icon_item)
.bind(payload.icon_bg)
.bind(payload.icon_border)
.bind(payload.requirement_type)
.bind(payload.requirement_key)
.bind(payload.requirement_value)
.bind(payload.xp_reward)
.bind(payload.secret.unwrap_or(false))
.bind(&now)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
}
/// Admin update achievement.
pub async fn admin_update_achievement(
_admin: AdminUser,
Path(id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<AchievementPayload>,
) -> AppResult<Json<Value>> {
sqlx::query(
"UPDATE achievements SET
title = ?,
description = ?,
category = ?,
icon_frame = ?,
icon_item = ?,
icon_bg = ?,
icon_border = ?,
requirement_type = ?,
requirement_key = ?,
requirement_value = ?,
xp_reward = ?,
secret = COALESCE(?, secret)
WHERE id = ?",
)
.bind(payload.title)
.bind(payload.description)
.bind(payload.category)
.bind(payload.icon_frame)
.bind(payload.icon_item)
.bind(payload.icon_bg)
.bind(payload.icon_border)
.bind(payload.requirement_type)
.bind(payload.requirement_key)
.bind(payload.requirement_value)
.bind(payload.xp_reward)
.bind(payload.secret)
.bind(&id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Admin delete achievement.
pub async fn admin_delete_achievement(
_admin: AdminUser,
Path(id): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM achievements WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}