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 })))
}
+829
View File
@@ -0,0 +1,829 @@
//! Guilds system, in-game relay, roles, posts, and land-claiming.
use crate::auth::AuthUser;
use crate::error::{AppError, AppResult};
use crate::routes::servers::GameServer;
use crate::state::AppState;
use axum::extract::{Path, Query, State};
use axum::Json;
use scopenet_shared::{Guild, GuildClaim, GuildMember, GuildPost};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Deserialize)]
pub struct InstanceQuery {
pub instance_id: Option<String>,
}
/// List guilds for an instance.
pub async fn list_guilds(
Query(query): Query<InstanceQuery>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<Guild>>> {
let instance_id = query.instance_id.unwrap_or_default();
let rows: Vec<(
String,
String,
String,
String,
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
i64,
String,
i64,
i64,
)> = sqlx::query_as(
"SELECT g.id, g.instance_id, g.name, g.tag, g.description, g.motd, g.leader_uuid,
g.icon_url, g.banner_url, g.level, g.xp, g.max_claims, g.created_at,
(SELECT COUNT(*) FROM guild_members gm WHERE gm.guild_id = g.id) as member_count,
(SELECT COUNT(*) FROM guild_claims gc WHERE gc.guild_id = g.id) as claims_count
FROM guilds g
WHERE (? = '' OR g.instance_id = ?)
ORDER BY g.level DESC, member_count DESC",
)
.bind(&instance_id)
.bind(&instance_id)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(
|(
id,
inst_id,
name,
tag,
desc,
motd,
leader_uuid,
icon_url,
banner_url,
level,
xp,
max_claims,
created_at,
member_count,
claims_count,
)| {
Guild {
id,
instance_id: inst_id,
name,
tag,
description: desc,
motd,
leader_uuid,
icon_url,
banner_url,
level,
xp,
max_claims,
member_count,
claims_count,
created_at,
}
},
)
.collect();
Ok(Json(list))
}
/// Get current user's guild for a specific instance.
pub async fn get_my_guild(
auth: AuthUser,
Query(query): Query<InstanceQuery>,
State(state): State<AppState>,
) -> AppResult<Json<Option<GuildDetail>>> {
let instance_id = query.instance_id.unwrap_or_default();
let guild_id: Option<String> = sqlx::query_scalar(
"SELECT g.id FROM guilds g
JOIN guild_members gm ON gm.guild_id = g.id
WHERE gm.uuid = ? AND (? = '' OR g.instance_id = ?)",
)
.bind(&auth.uuid)
.bind(&instance_id)
.bind(&instance_id)
.fetch_optional(&state.db)
.await?;
let Some(gid) = guild_id else {
return Ok(Json(None));
};
let detail = fetch_guild_detail(&state, &gid).await?;
Ok(Json(Some(detail)))
}
#[derive(Serialize)]
pub struct GuildDetail {
#[serde(flatten)]
pub guild: Guild,
pub members: Vec<GuildMember>,
pub posts: Vec<GuildPost>,
pub claims: Vec<GuildClaim>,
}
async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult<GuildDetail> {
let row: Option<(
String,
String,
String,
String,
String,
String,
String,
Option<String>,
Option<String>,
i64,
i64,
i64,
String,
)> = sqlx::query_as(
"SELECT id, instance_id, name, tag, description, motd, leader_uuid,
icon_url, banner_url, level, xp, max_claims, created_at
FROM guilds WHERE id = ?",
)
.bind(guild_id)
.fetch_optional(&state.db)
.await?;
let (
id,
inst_id,
name,
tag,
desc,
motd,
leader_uuid,
icon_url,
banner_url,
level,
xp,
max_claims,
created_at,
) = row.ok_or_else(|| AppError::not_found("Guild not found"))?;
// Members with online presence
let member_rows: Vec<(String, String, String, String, bool)> = sqlx::query_as(
"SELECT gm.uuid, gm.name, gm.role, gm.joined_at,
EXISTS(SELECT 1 FROM server_online so WHERE so.uuid = gm.uuid) as online
FROM guild_members gm
WHERE gm.guild_id = ?
ORDER BY (CASE gm.role WHEN 'leader' THEN 1 WHEN 'officer' THEN 2 ELSE 3 END) ASC, gm.joined_at ASC",
)
.bind(guild_id)
.fetch_all(&state.db)
.await?;
let members: Vec<GuildMember> = member_rows
.into_iter()
.map(|(uuid, mname, role, joined_at, online)| GuildMember {
uuid,
name: mname,
role,
joined_at,
online,
})
.collect();
// Posts
let post_rows: Vec<(i64, String, String, String, String, String, String)> = sqlx::query_as(
"SELECT id, guild_id, author_uuid, author_name, title, content, created_at
FROM guild_posts
WHERE guild_id = ?
ORDER BY id DESC LIMIT 20",
)
.bind(guild_id)
.fetch_all(&state.db)
.await?;
let posts: Vec<GuildPost> = post_rows
.into_iter()
.map(|(pid, gid, auuid, aname, title, content, pcreated)| GuildPost {
id: pid,
guild_id: gid,
author_uuid: auuid,
author_name: aname,
title,
content,
created_at: pcreated,
})
.collect();
// Claims
let claim_rows: Vec<(i64, String, i64, String, i32, i32, String, String)> = sqlx::query_as(
"SELECT id, guild_id, server_id, dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at
FROM guild_claims
WHERE guild_id = ?
LIMIT 200",
)
.bind(guild_id)
.fetch_all(&state.db)
.await?;
let claims: Vec<GuildClaim> = claim_rows
.into_iter()
.map(
|(cid, gid, sid, dim, cx, cz, cby, cat)| GuildClaim {
id: cid,
guild_id: gid,
guild_name: name.clone(),
guild_tag: tag.clone(),
server_id: sid,
dimension: dim,
chunk_x: cx,
chunk_z: cz,
claimed_by_uuid: cby,
claimed_at: cat,
},
)
.collect();
Ok(GuildDetail {
guild: Guild {
id,
instance_id: inst_id,
name,
tag,
description: desc,
motd,
leader_uuid,
icon_url,
banner_url,
level,
xp,
max_claims,
member_count: members.len() as i64,
claims_count: claims.len() as i64,
created_at,
},
members,
posts,
claims,
})
}
/// Get guild detail by ID.
pub async fn get_guild_by_id(
Path(id): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<GuildDetail>> {
fetch_guild_detail(&state, &id).await.map(Json)
}
#[derive(Deserialize)]
pub struct CreateGuildPayload {
pub instance_id: String,
pub name: String,
pub tag: String,
pub description: Option<String>,
pub motd: Option<String>,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
}
/// Create a new Guild.
pub async fn create_guild(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateGuildPayload>,
) -> AppResult<Json<GuildDetail>> {
let name = payload.name.trim();
let tag = payload.tag.trim();
if name.len() < 3 || name.len() > 32 {
return Err(AppError::bad_request("Guild name must be 3-32 characters"));
}
if tag.len() < 2 || tag.len() > 6 {
return Err(AppError::bad_request("Guild tag must be 2-6 characters"));
}
// Check if user is already in a guild for this instance
let in_guild: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM guild_members gm
JOIN guilds g ON g.id = gm.guild_id
WHERE gm.uuid = ? AND g.instance_id = ?
)",
)
.bind(&auth.uuid)
.bind(&payload.instance_id)
.fetch_one(&state.db)
.await?;
if in_guild {
return Err(AppError::bad_request("You are already in a guild for this instance"));
}
let mut tx = state.db.begin().await?;
let guild_id = format!("guild_{}", uuid::Uuid::new_v4().simple());
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, icon_url, banner_url, level, xp, max_claims, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 16, ?)",
)
.bind(&guild_id)
.bind(&payload.instance_id)
.bind(name)
.bind(tag.to_ascii_uppercase())
.bind(payload.description.unwrap_or_default())
.bind(payload.motd.unwrap_or_default())
.bind(&auth.uuid)
.bind(payload.icon_url)
.bind(payload.banner_url)
.bind(&now)
.execute(&mut *tx)
.await?;
// Add leader as first member
sqlx::query(
"INSERT INTO guild_members (guild_id, uuid, name, role, joined_at)
VALUES (?, ?, ?, 'leader', ?)",
)
.bind(&guild_id)
.bind(&auth.uuid)
.bind(&auth.username)
.bind(&now)
.execute(&mut *tx)
.await?;
// Record achievement event for founding a guild
sqlx::query(
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at)
VALUES (?, 'ach_guild_initiate', ?)",
)
.bind(&auth.uuid)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
fetch_guild_detail(&state, &guild_id).await.map(Json)
}
#[derive(Deserialize)]
pub struct UpdateGuildPayload {
pub description: Option<String>,
pub motd: Option<String>,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
}
/// Update guild info.
pub async fn update_guild(
auth: AuthUser,
Path(id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<UpdateGuildPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
.bind(&id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
.await?;
let is_officer_or_leader = role.as_deref().is_some_and(|r| r == "leader" || r == "officer");
if !is_officer_or_leader {
return Err(AppError::forbidden("Only guild officers or leaders can update guild details"));
}
sqlx::query(
"UPDATE guilds SET
description = COALESCE(?, description),
motd = COALESCE(?, motd),
icon_url = COALESCE(?, icon_url),
banner_url = COALESCE(?, banner_url)
WHERE id = ?",
)
.bind(payload.description)
.bind(payload.motd)
.bind(payload.icon_url)
.bind(payload.banner_url)
.bind(&id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct AddMemberPayload {
pub username: String,
}
/// Add / Invite member to guild.
pub async fn add_guild_member(
auth: AuthUser,
Path(id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<AddMemberPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
.bind(&id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
.await?;
if !role.as_deref().is_some_and(|r| r == "leader" || r == "officer") {
return Err(AppError::forbidden("Only guild leaders or officers can invite members"));
}
let target_user: Option<(String, String)> = sqlx::query_as(
"SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE",
)
.bind(payload.username.trim())
.fetch_optional(&state.db)
.await?;
let (target_uuid, target_name) = target_user.ok_or_else(|| AppError::not_found("User not found"))?;
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO guild_members (guild_id, uuid, name, role, joined_at)
VALUES (?, ?, ?, 'member', ?)
ON CONFLICT (guild_id, uuid) DO NOTHING",
)
.bind(&id)
.bind(&target_uuid)
.bind(&target_name)
.bind(&now)
.execute(&state.db)
.await?;
// Record achievement event for joining a guild
sqlx::query(
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at)
VALUES (?, 'ach_guild_initiate', ?)",
)
.bind(&target_uuid)
.bind(&now)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Leave or kick member.
pub async fn remove_guild_member(
auth: AuthUser,
Path((guild_id, target_uuid)): Path<(String, String)>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let caller_role: Option<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
.bind(&guild_id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
.await?;
let is_self = auth.uuid == target_uuid;
let is_leader_or_officer = caller_role.as_deref().is_some_and(|r| r == "leader" || r == "officer");
if !is_self && !is_leader_or_officer {
return Err(AppError::forbidden("Cannot kick member without officer privileges"));
}
if is_self && caller_role.as_deref() == Some("leader") {
return Err(AppError::bad_request("Guild leader cannot leave without transferring leadership"));
}
sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?")
.bind(&guild_id)
.bind(&target_uuid)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// Guild Posts
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct CreatePostPayload {
pub title: String,
pub content: String,
}
pub async fn create_guild_post(
auth: AuthUser,
Path(id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<CreatePostPayload>,
) -> AppResult<Json<Value>> {
let in_guild: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)",
)
.bind(&id)
.bind(&auth.uuid)
.fetch_one(&state.db)
.await?;
if !in_guild {
return Err(AppError::forbidden("Must be a guild member to post"));
}
let now = chrono::Utc::now().to_rfc3339();
let post_id: i64 = sqlx::query_scalar(
"INSERT INTO guild_posts (guild_id, author_uuid, author_name, title, content, created_at)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(&id)
.bind(&auth.uuid)
.bind(&auth.username)
.bind(payload.title)
.bind(payload.content)
.bind(&now)
.fetch_one(&state.db)
.await?;
Ok(Json(serde_json::json!({ "id": post_id, "ok": true })))
}
// ---------------------------------------------------------------------------
// Land Claiming API (FTB Chunks-style grid)
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct ClaimChunkPayload {
pub server_id: Option<i64>,
pub instance_id: Option<String>,
pub dimension: Option<String>,
pub chunk_x: i32,
pub chunk_z: i32,
}
/// Claim a chunk for your guild.
pub async fn claim_chunk(
auth: AuthUser,
Path(guild_id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<ClaimChunkPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
.bind(&guild_id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
.await?;
if role.is_none() {
return Err(AppError::forbidden("You are not a member of this guild"));
}
let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?")
.bind(&guild_id)
.fetch_one(&state.db)
.await?;
let current_claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?")
.bind(&guild_id)
.fetch_one(&state.db)
.await?;
if current_claims >= max_claims {
return Err(AppError::bad_request(format!("Guild reached its max claim limit of {max_claims} chunks")));
}
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into());
let now = chrono::Utc::now().to_rfc3339();
let res = sqlx::query(
"INSERT INTO guild_claims (guild_id, server_id, dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(&guild_id)
.bind(payload.server_id)
.bind(&dim)
.bind(payload.chunk_x)
.bind(payload.chunk_z)
.bind(&auth.uuid)
.bind(&now)
.execute(&state.db)
.await;
if let Err(e) = res {
tracing::warn!("claim chunk failed: {e}");
return Err(AppError::bad_request("Chunk is already claimed by another guild"));
}
let claim_id: i64 = sqlx::query_scalar("SELECT last_insert_rowid()").fetch_one(&state.db).await.unwrap_or(1);
// Award achievement for claiming land
sqlx::query(
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at)
VALUES (?, 'ach_land_claim', ?)",
)
.bind(&auth.uuid)
.bind(&now)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true, "id": claim_id, "chunk_x": payload.chunk_x, "chunk_z": payload.chunk_z })))
}
/// Unclaim a chunk by coordinates.
pub async fn unclaim_chunk(
auth: AuthUser,
Path(guild_id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<ClaimChunkPayload>,
) -> AppResult<Json<Value>> {
let role: Option<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
.bind(&guild_id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
.await?;
if role.is_none() {
return Err(AppError::forbidden("You are not a member of this guild"));
}
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into());
sqlx::query(
"DELETE FROM guild_claims WHERE guild_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?",
)
.bind(&guild_id)
.bind(&dim)
.bind(payload.chunk_x)
.bind(payload.chunk_z)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Unclaim by claim ID.
pub async fn unclaim_by_id(
auth: AuthUser,
Path(claim_id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let claim: Option<(String,)> = sqlx::query_as("SELECT guild_id FROM guild_claims WHERE id = ?")
.bind(claim_id)
.fetch_optional(&state.db)
.await?;
let (guild_id,) = claim.ok_or_else(|| AppError::not_found("Claim not found"))?;
let role: Option<String> = sqlx::query_scalar(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
.bind(&guild_id)
.bind(&auth.uuid)
.fetch_optional(&state.db)
.await?;
if role.is_none() {
return Err(AppError::forbidden("You are not a member of this guild"));
}
sqlx::query("DELETE FROM guild_claims WHERE id = ?")
.bind(claim_id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct ChunkGridQuery {
pub server_id: i64,
pub dimension: Option<String>,
pub center_x: i32,
pub center_z: i32,
pub radius: Option<i32>,
}
/// Returns chunk claims in a bounding box centered around (center_x, center_z) chunks.
pub async fn get_chunk_grid(
Query(query): Query<ChunkGridQuery>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<GuildClaim>>> {
let dim = query.dimension.unwrap_or_else(|| "minecraft:overworld".into());
let r = query.radius.unwrap_or(8).clamp(2, 24);
let min_x = query.center_x - r;
let max_x = query.center_x + r;
let min_z = query.center_z - r;
let max_z = query.center_z + r;
let rows: Vec<(i64, String, String, String, i64, String, i32, i32, String, String)> = sqlx::query_as(
"SELECT gc.id, gc.guild_id, g.name, g.tag, gc.server_id, gc.dimension,
gc.chunk_x, gc.chunk_z, gc.claimed_by_uuid, gc.claimed_at
FROM guild_claims gc
JOIN guilds g ON g.id = gc.guild_id
WHERE gc.server_id = ? AND gc.dimension = ?
AND gc.chunk_x BETWEEN ? AND ?
AND gc.chunk_z BETWEEN ? AND ?
LIMIT 500",
)
.bind(query.server_id)
.bind(&dim)
.bind(min_x)
.bind(max_x)
.bind(min_z)
.bind(max_z)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(
|(id, gid, gname, gtag, sid, gdim, cx, cz, cby, cat)| GuildClaim {
id,
guild_id: gid,
guild_name: gname,
guild_tag: gtag,
server_id: sid,
dimension: gdim,
chunk_x: cx,
chunk_z: cz,
claimed_by_uuid: cby,
claimed_at: cat,
},
)
.collect();
Ok(Json(list))
}
// ---------------------------------------------------------------------------
// Server Integration / Plugin In-Game Land Claim Relay
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct ServerCheckChunkPayload {
pub uuid: String,
pub dimension: String,
pub chunk_x: i32,
pub chunk_z: i32,
}
/// Token-authenticated check called by the Minecraft server plugin/mod to
/// verify if a player can build/break in a chunk.
pub async fn server_check_chunk(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerCheckChunkPayload>,
) -> AppResult<Json<Value>> {
let claim: Option<(String, String, String)> = sqlx::query_as(
"SELECT gc.guild_id, g.name, g.tag
FROM guild_claims gc
JOIN guilds g ON g.id = gc.guild_id
WHERE gc.server_id = ? AND gc.dimension = ? AND gc.chunk_x = ? AND gc.chunk_z = ?",
)
.bind(server.id)
.bind(&payload.dimension)
.bind(payload.chunk_x)
.bind(payload.chunk_z)
.fetch_optional(&state.db)
.await?;
let Some((guild_id, guild_name, guild_tag)) = claim else {
// Wilderness / unclaimed land is allowed
return Ok(Json(serde_json::json!({
"claimed": false,
"allowed": true
})));
};
// Check if player is a member of this guild
let is_member: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)",
)
.bind(&guild_id)
.bind(&payload.uuid)
.fetch_one(&state.db)
.await?;
Ok(Json(serde_json::json!({
"claimed": true,
"guild_id": guild_id,
"guild_name": guild_name,
"guild_tag": guild_tag,
"allowed": is_member,
"message": if is_member { None } else { Some(format!("This land is claimed by [{guild_tag}] {guild_name}")) }
})))
}
+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 })))
}
+62 -1
View File
@@ -1,14 +1,19 @@
pub mod account;
pub mod achievements;
pub mod activity;
pub mod admin;
pub mod guilds;
pub mod landing;
pub mod leveling;
pub mod meta;
pub mod public;
pub mod quests;
pub mod servers;
pub mod social;
use crate::state::AppState;
use axum::extract::DefaultBodyLimit;
use axum::routing::{delete, get, post};
use axum::routing::{delete, get, post, put};
use axum::Router;
pub fn api(state: &AppState) -> Router<AppState> {
@@ -34,6 +39,54 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/landing", get(landing::public_landing))
.route("/launcher/authlib-injector.json", get(account::authlib_index))
.route("/launcher/authlib-injector.jar", get(account::authlib_jar))
// Leveling & Rewards
.route("/levels/me", get(leveling::get_my_levels))
.route("/leveling/me", get(leveling::get_my_levels))
.route("/levels/player/{uuid}", get(leveling::get_player_levels))
.route("/levels/leaderboard", get(leveling::get_level_leaderboard))
.route("/levels/rewards", get(leveling::list_rewards))
// Daily & Weekly Quests
.route("/quests/my", get(quests::get_my_quests))
.route("/quests/me", get(quests::get_my_quests))
.route("/quests/{id}/claim", post(quests::claim_quest))
// Achievements
.route("/achievements/my", get(achievements::get_my_achievements))
.route("/achievements/me", get(achievements::get_my_achievements))
.route("/achievements/player/{uuid}", get(achievements::get_player_achievements))
// Guilds & Land Claims
.route("/guilds", get(guilds::list_guilds).post(guilds::create_guild))
.route("/guilds/my", get(guilds::get_my_guild))
.route("/guilds/claims/grid", get(guilds::get_chunk_grid))
.route("/guilds/claims/{id}", delete(guilds::unclaim_by_id))
.route("/guilds/{id}", get(guilds::get_guild_by_id).put(guilds::update_guild))
.route("/guilds/{id}/members", post(guilds::add_guild_member))
.route("/guilds/{id}/members/{uuid}", delete(guilds::remove_guild_member))
.route("/guilds/{id}/posts", post(guilds::create_guild_post))
.route("/guilds/{id}/claims", post(guilds::claim_chunk).delete(guilds::unclaim_chunk))
.route("/guilds/{id}/claim", post(guilds::claim_chunk))
.route("/guilds/{id}/unclaim", post(guilds::unclaim_chunk))
// Social: Friends, DMs, Invites, Profiles, Posts
.route("/friends", get(social::list_friends))
.route("/friends/request", post(social::send_friend_request))
.route("/friends/respond", post(social::respond_friend_request))
.route("/friends/{uuid}/accept", post(social::accept_friend_request))
.route("/friends/{uuid}", delete(social::remove_friend))
.route("/messages/{uuid}", get(social::get_direct_messages).post(social::send_direct_message))
.route("/invites", get(social::list_my_game_invites).post(social::send_game_invite))
.route("/invites/{id}/respond", post(social::respond_game_invite))
.route("/profiles/me", put(social::update_my_profile))
.route("/profiles/{uuid}", get(social::get_player_profile))
.route("/profiles/me/posts", post(social::create_user_post))
.route("/profiles/me/posts/{id}", delete(social::delete_user_post))
.route("/posts/{id}/like", post(social::like_user_post))
.route("/social/friends", get(social::list_friends))
.route("/social/friends/request", post(social::send_friend_request))
.route("/social/friends/respond", post(social::respond_friend_request))
.route("/social/messages/{uuid}", get(social::get_direct_messages).post(social::send_direct_message))
.route("/social/profile/me", put(social::update_my_profile))
.route("/social/profile/{uuid}", get(social::get_player_profile))
.route("/social/posts", post(social::create_user_post))
.route("/social/posts/{id}/like", post(social::like_user_post))
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
let admin = Router::new()
@@ -71,6 +124,13 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/modrinth/project/{id}/versions", get(meta::modrinth_versions))
.route("/curseforge/search", get(meta::curseforge_search))
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
// Admin Quests & Achievements & Rewards
.route("/rewards", post(leveling::admin_create_reward))
.route("/rewards/{id}", delete(leveling::admin_delete_reward))
.route("/quests", get(quests::admin_list_quests).post(quests::admin_create_quest))
.route("/quests/{id}", put(quests::admin_update_quest).delete(quests::admin_delete_quest))
.route("/achievements", get(achievements::admin_list_achievements).post(achievements::admin_create_achievement))
.route("/achievements/{id}", put(achievements::admin_update_achievement).delete(achievements::admin_delete_achievement))
.layer(DefaultBodyLimit::max(upload_limit));
// Called by the SCOPENET Paper plugin and Fabric/Forge mods.
@@ -78,6 +138,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/hello", post(servers::hello))
.route("/login", post(servers::login))
.route("/sync", post(servers::sync))
.route("/guilds/check-chunk", post(guilds::server_check_chunk))
.layer(DefaultBodyLimit::max(2 * 1024 * 1024));
Router::new()
+373
View File
@@ -0,0 +1,373 @@
//! Daily and Weekly Quests system.
use crate::auth::{AdminUser, AuthUser};
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::Json;
use scopenet_shared::{Quest, UserQuest};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub fn current_daily_key() -> String {
chrono::Utc::now().format("%Y-%m-%d").to_string()
}
pub fn current_weekly_key() -> String {
chrono::Utc::now().format("%Y-W%V").to_string()
}
/// Get all active daily and weekly quests for the authenticated user with live progress.
pub async fn get_my_quests(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<UserQuest>>> {
let daily_key = current_daily_key();
let weekly_key = current_weekly_key();
let rows: Vec<(
String,
String,
String,
String,
String,
String,
i64,
i64,
String,
bool,
Option<i64>,
Option<bool>,
Option<bool>,
Option<String>,
)> = sqlx::query_as(
"SELECT q.id, q.title, q.description, q.period, q.category, q.target_stat,
q.target_count, q.xp_reward, q.icon, q.enabled,
uq.progress, uq.completed, uq.claimed, uq.period_key
FROM quests q
LEFT JOIN user_quests uq ON uq.quest_id = q.id
AND uq.user_uuid = ?
AND uq.period_key = (CASE WHEN q.period = 'weekly' THEN ? ELSE ? END)
WHERE q.enabled = 1
ORDER BY q.period ASC, q.xp_reward ASC",
)
.bind(&auth.uuid)
.bind(&weekly_key)
.bind(&daily_key)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(
|(
id,
title,
desc,
period,
cat,
stat,
target,
xp,
icon,
enabled,
progress_opt,
completed_opt,
claimed_opt,
period_key_opt,
)| {
let progress = progress_opt.unwrap_or(0);
let completed = completed_opt.unwrap_or(progress >= target);
let claimed = claimed_opt.unwrap_or(false);
let pkey = period_key_opt.unwrap_or_else(|| {
if period == "weekly" {
weekly_key.clone()
} else {
daily_key.clone()
}
});
UserQuest {
quest: Quest {
id,
title,
description: desc,
period,
category: cat,
target_stat: stat,
target_count: target,
xp_reward: xp,
icon,
enabled,
},
progress,
completed,
claimed,
period_key: pkey,
}
},
)
.collect();
Ok(Json(list))
}
/// Claim a completed quest reward.
pub async fn claim_quest(
auth: AuthUser,
Path(quest_id): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let mut tx = state.db.begin().await?;
let quest: Option<(String, i64, i64)> = sqlx::query_as(
"SELECT period, target_count, xp_reward FROM quests WHERE id = ? AND enabled = 1",
)
.bind(&quest_id)
.fetch_optional(&mut *tx)
.await?;
let (period, target_count, xp_reward) =
quest.ok_or_else(|| AppError::bad_request("Quest not found or disabled"))?;
let period_key = if period == "weekly" {
current_weekly_key()
} else {
current_daily_key()
};
let user_quest: Option<(i64, bool, bool)> = sqlx::query_as(
"SELECT progress, completed, claimed FROM user_quests WHERE user_uuid = ? AND quest_id = ? AND period_key = ?",
)
.bind(&auth.uuid)
.bind(&quest_id)
.bind(&period_key)
.fetch_optional(&mut *tx)
.await?;
let (progress, completed, claimed) = user_quest.unwrap_or((0, false, false));
if claimed {
return Err(AppError::bad_request("Quest reward already claimed for this period"));
}
if !completed && progress < target_count {
return Err(AppError::bad_request("Quest is not yet completed"));
}
let now = chrono::Utc::now().to_rfc3339();
// Mark as completed & claimed
sqlx::query(
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
VALUES (?, ?, ?, ?, 1, 1, ?)
ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET
completed = 1,
claimed = 1,
updated_at = excluded.updated_at",
)
.bind(&auth.uuid)
.bind(&quest_id)
.bind(&period_key)
.bind(progress.max(target_count))
.bind(&now)
.execute(&mut *tx)
.await?;
// Grant XP to user_levels
sqlx::query(
"INSERT INTO user_levels (uuid, global_xp, global_level, updated_at)
VALUES (?, ?, 1, ?)
ON CONFLICT (uuid) DO UPDATE SET
global_xp = global_xp + excluded.global_xp,
updated_at = excluded.updated_at",
)
.bind(&auth.uuid)
.bind(xp_reward)
.bind(&now)
.execute(&mut *tx)
.await?;
// Read new total XP & compute new level
let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
.bind(&auth.uuid)
.fetch_one(&mut *tx)
.await?;
let (new_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_xp);
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?")
.bind(new_lvl)
.bind(&auth.uuid)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({
"ok": true,
"xp_granted": xp_reward,
"new_global_xp": new_xp,
"new_global_level": new_lvl
})))
}
// ---------------------------------------------------------------------------
// Admin Quests API
// ---------------------------------------------------------------------------
#[derive(Serialize)]
pub struct AdminQuestRow {
#[serde(flatten)]
pub quest: Quest,
pub total_completions: i64,
}
/// Admin list all quests.
pub async fn admin_list_quests(
_admin: AdminUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<AdminQuestRow>>> {
let rows: Vec<(
String,
String,
String,
String,
String,
String,
i64,
i64,
String,
bool,
i64,
)> = sqlx::query_as(
"SELECT q.id, q.title, q.description, q.period, q.category, q.target_stat,
q.target_count, q.xp_reward, q.icon, q.enabled,
COUNT(uq.user_uuid) as total_completions
FROM quests q
LEFT JOIN user_quests uq ON uq.quest_id = q.id AND uq.completed = 1
GROUP BY q.id
ORDER BY q.period ASC, q.created_at DESC",
)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(
|(id, title, desc, period, cat, stat, target, xp, icon, enabled, completions)| {
AdminQuestRow {
quest: Quest {
id,
title,
description: desc,
period,
category: cat,
target_stat: stat,
target_count: target,
xp_reward: xp,
icon,
enabled,
},
total_completions: completions,
}
},
)
.collect();
Ok(Json(list))
}
#[derive(Deserialize)]
pub struct QuestPayload {
pub id: Option<String>,
pub title: String,
pub description: String,
pub period: String,
pub category: String,
pub target_stat: String,
pub target_count: i64,
pub xp_reward: i64,
pub icon: Option<String>,
pub enabled: Option<bool>,
}
/// Admin create quest.
pub async fn admin_create_quest(
_admin: AdminUser,
State(state): State<AppState>,
Json(payload): Json<QuestPayload>,
) -> AppResult<Json<Value>> {
let id = payload
.id
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| format!("q_{}_{}", payload.period, uuid::Uuid::new_v4().simple()));
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO quests (id, title, description, period, category, target_stat, target_count, xp_reward, icon, enabled, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(payload.title)
.bind(payload.description)
.bind(payload.period)
.bind(payload.category)
.bind(payload.target_stat)
.bind(payload.target_count)
.bind(payload.xp_reward)
.bind(payload.icon.unwrap_or_else(|| "star".into()))
.bind(payload.enabled.unwrap_or(true))
.bind(&now)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
}
/// Admin update quest.
pub async fn admin_update_quest(
_admin: AdminUser,
Path(id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<QuestPayload>,
) -> AppResult<Json<Value>> {
sqlx::query(
"UPDATE quests SET
title = ?,
description = ?,
period = ?,
category = ?,
target_stat = ?,
target_count = ?,
xp_reward = ?,
icon = COALESCE(?, icon),
enabled = COALESCE(?, enabled)
WHERE id = ?",
)
.bind(payload.title)
.bind(payload.description)
.bind(payload.period)
.bind(payload.category)
.bind(payload.target_stat)
.bind(payload.target_count)
.bind(payload.xp_reward)
.bind(payload.icon)
.bind(payload.enabled)
.bind(&id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Admin delete quest.
pub async fn admin_delete_quest(
_admin: AdminUser,
Path(id): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM quests WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+277 -1
View File
@@ -395,6 +395,19 @@ pub struct GameEvent {
at: Option<i64>,
}
#[derive(Deserialize, Default, Clone, Debug)]
#[serde(default)]
pub struct FeatureToggles {
pub leveling_enabled: Option<bool>,
pub global_xp_multiplier: Option<f64>,
pub server_xp_multiplier: Option<f64>,
pub quests_enabled: Option<bool>,
pub achievements_enabled: Option<bool>,
pub guilds_enabled: Option<bool>,
pub land_claiming_enabled: Option<bool>,
pub social_enabled: Option<bool>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct Sync {
@@ -404,6 +417,7 @@ pub struct Sync {
online: Vec<OnlinePlayer>,
stats: Vec<StatDelta>,
events: Vec<GameEvent>,
features: Option<FeatureToggles>,
}
/// Periodic heartbeat: who's online, TPS, stat deltas and notable events.
@@ -490,8 +504,12 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
.bind(&at_now)
.execute(&mut *tx)
.await?;
update_progression_for_delta(&mut tx, server.id, &uuid, d, &at_now, s.features.as_ref()).await?;
}
let achievements_on = s.features.as_ref().and_then(|f| f.achievements_enabled).unwrap_or(true);
for e in s.events.iter().filter(|_| fresh).take(MAX_EVENTS_PER_SYNC) {
let kind = clip(&e.kind, 24).to_ascii_lowercase();
if kind.is_empty() {
@@ -506,11 +524,34 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
.bind(server.id)
.bind(e.uuid.as_deref().and_then(dashed))
.bind(e.name.as_deref().map(|n| clip(n, 16)))
.bind(kind)
.bind(&kind)
.bind(e.detail.as_deref().map(|d| clip(d, 256)))
.bind(at)
.execute(&mut *tx)
.await?;
// Check if event unlocks an achievement (if achievements enabled)
if achievements_on {
if let Some(ref euuid) = e.uuid.as_deref().and_then(dashed) {
let event_ach: Vec<String> = sqlx::query_scalar(
"SELECT id FROM achievements WHERE requirement_type = 'event' AND requirement_key = ?",
)
.bind(&kind)
.fetch_all(&mut *tx)
.await?;
for ach_id in event_ach {
sqlx::query(
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
)
.bind(euuid)
.bind(ach_id)
.bind(&at_now)
.execute(&mut *tx)
.await?;
}
}
}
}
tx.commit().await?;
@@ -533,6 +574,241 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
Ok(Json(json!({ "ok": true, "kick": kick, "sync_interval_secs": SYNC_INTERVAL_SECS })))
}
async fn update_progression_for_delta(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
server_id: i64,
uuid: &str,
d: &StatDelta,
at_now: &str,
features: Option<&FeatureToggles>,
) -> AppResult<()> {
let leveling_on = features.and_then(|f| f.leveling_enabled).unwrap_or(true);
let quests_on = features.and_then(|f| f.quests_enabled).unwrap_or(true);
let achievements_on = features.and_then(|f| f.achievements_enabled).unwrap_or(true);
let global_mult = features.and_then(|f| f.global_xp_multiplier).unwrap_or(1.0).max(0.0);
let server_mult = features.and_then(|f| f.server_xp_multiplier).unwrap_or(1.5).max(0.0);
// 1. Calculate XP gains: Global XP and Server XP (only if leveling enabled)
if leveling_on {
let base_xp = (d.playtime_secs / 6)
+ (d.player_kills * 50)
+ (d.mob_kills * 15)
+ (d.blocks_broken / 5)
+ (d.blocks_placed / 5)
+ (d.messages * 2);
let delta_xp = (base_xp as f64 * global_mult).round() as i64;
let server_delta_xp = (base_xp as f64 * server_mult).round() as i64;
if delta_xp > 0 {
sqlx::query(
"INSERT INTO user_levels (uuid, global_xp, global_level, updated_at)
VALUES (?, ?, 1, ?)
ON CONFLICT (uuid) DO UPDATE SET
global_xp = global_xp + excluded.global_xp,
updated_at = excluded.updated_at",
)
.bind(uuid)
.bind(delta_xp)
.bind(at_now)
.execute(&mut **tx)
.await?;
// Recompute global level
let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
.bind(uuid)
.fetch_one(&mut **tx)
.await?;
let (new_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_xp);
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?")
.bind(new_lvl)
.bind(uuid)
.execute(&mut **tx)
.await?;
}
if server_delta_xp > 0 {
sqlx::query(
"INSERT INTO server_levels (server_id, uuid, server_xp, server_level, updated_at)
VALUES (?, ?, ?, 1, ?)
ON CONFLICT (server_id, uuid) DO UPDATE SET
server_xp = server_xp + excluded.server_xp,
updated_at = excluded.updated_at",
)
.bind(server_id)
.bind(uuid)
.bind(server_delta_xp)
.bind(at_now)
.execute(&mut **tx)
.await?;
// Recompute server level
let new_s_xp: i64 = sqlx::query_scalar("SELECT server_xp FROM server_levels WHERE server_id = ? AND uuid = ?")
.bind(server_id)
.bind(uuid)
.fetch_one(&mut **tx)
.await?;
let (new_s_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_s_xp);
sqlx::query("UPDATE server_levels SET server_level = ? WHERE server_id = ? AND uuid = ?")
.bind(new_s_lvl)
.bind(server_id)
.bind(uuid)
.execute(&mut **tx)
.await?;
}
}
// 2. Advance active Daily & Weekly Quests (if quests enabled)
if quests_on {
let stat_deltas = [
("playtime_secs", d.playtime_secs),
("joins", d.joins),
("player_kills", d.player_kills),
("mob_kills", d.mob_kills),
("blocks_broken", d.blocks_broken),
("blocks_placed", d.blocks_placed),
("messages", d.messages),
];
let daily_key = crate::routes::quests::current_daily_key();
let weekly_key = crate::routes::quests::current_weekly_key();
for (stat_name, amount) in stat_deltas {
if amount <= 0 {
continue;
}
// Daily quests matching this stat
let d_quests: Vec<(String, i64)> = sqlx::query_as(
"SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'daily' AND target_stat = ?",
)
.bind(stat_name)
.fetch_all(&mut **tx)
.await?;
for (qid, target) in d_quests {
sqlx::query(
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?)
ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET
progress = progress + excluded.progress,
completed = CASE WHEN (progress + excluded.progress) >= ? THEN 1 ELSE completed END,
updated_at = excluded.updated_at",
)
.bind(uuid)
.bind(&qid)
.bind(&daily_key)
.bind(amount)
.bind(if amount >= target { 1 } else { 0 })
.bind(at_now)
.bind(target)
.execute(&mut **tx)
.await?;
}
// Weekly quests matching this stat
let w_quests: Vec<(String, i64)> = sqlx::query_as(
"SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'weekly' AND target_stat = ?",
)
.bind(stat_name)
.fetch_all(&mut **tx)
.await?;
for (qid, target) in w_quests {
sqlx::query(
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?)
ON CONFLICT (user_uuid, quest_id, period_key) DO UPDATE SET
progress = progress + excluded.progress,
completed = CASE WHEN (progress + excluded.progress) >= ? THEN 1 ELSE completed END,
updated_at = excluded.updated_at",
)
.bind(uuid)
.bind(&qid)
.bind(&weekly_key)
.bind(amount)
.bind(if amount >= target { 1 } else { 0 })
.bind(at_now)
.bind(target)
.execute(&mut **tx)
.await?;
}
}
}
// 3. Milestone Achievements Check (if achievements enabled)
if achievements_on {
let total_stats: Option<(Option<i64>, Option<i64>, Option<i64>, Option<i64>, Option<i64>, Option<i64>)> = sqlx::query_as(
"SELECT SUM(playtime_secs), SUM(player_kills), SUM(mob_kills), SUM(blocks_broken), SUM(blocks_placed), SUM(messages)
FROM player_stats WHERE uuid = ?",
)
.bind(uuid)
.fetch_optional(&mut **tx)
.await?;
if let Some((playtime, pkills, mkills, bbroken, bplaced, msgs)) = total_stats {
let checks = [
("blocks_broken", bbroken.unwrap_or(0)),
("blocks_placed", bplaced.unwrap_or(0)),
("player_kills", pkills.unwrap_or(0)),
("mob_kills", mkills.unwrap_or(0)),
("playtime_secs", playtime.unwrap_or(0)),
("messages", msgs.unwrap_or(0)),
];
for (key, val) in checks {
let eligible: Vec<String> = sqlx::query_scalar(
"SELECT id FROM achievements WHERE requirement_type = 'stat' AND requirement_key = ? AND requirement_value <= ?",
)
.bind(key)
.bind(val)
.fetch_all(&mut **tx)
.await?;
for ach_id in eligible {
sqlx::query(
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
)
.bind(uuid)
.bind(ach_id)
.bind(at_now)
.execute(&mut **tx)
.await?;
}
}
}
}
// Level milestone achievements
let global_level: Option<i64> = sqlx::query_scalar("SELECT global_level FROM user_levels WHERE uuid = ?")
.bind(uuid)
.fetch_optional(&mut **tx)
.await?;
if let Some(lvl) = global_level {
let lvl_ach: Vec<String> = sqlx::query_scalar(
"SELECT id FROM achievements WHERE requirement_type = 'level' AND requirement_value <= ?",
)
.bind(lvl)
.fetch_all(&mut **tx)
.await?;
for ach_id in lvl_ach {
sqlx::query(
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
)
.bind(uuid)
.bind(ach_id)
.bind(at_now)
.execute(&mut **tx)
.await?;
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Admin API
// ---------------------------------------------------------------------------
+623
View File
@@ -0,0 +1,623 @@
//! Friends, Direct Messages, Game Invites, Player Profiles, and Social Feed.
use crate::auth::AuthUser;
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::Json;
use scopenet_shared::{DirectMessage, FriendInfo, GameInvite, UserPost, UserProfileView};
use serde::Deserialize;
use serde_json::Value;
// ---------------------------------------------------------------------------
// Friends System
// ---------------------------------------------------------------------------
pub async fn list_friends(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<FriendInfo>>> {
let rows: Vec<(String, String, String, String, bool, Option<String>)> = sqlx::query_as(
"SELECT
(CASE WHEN f.user_uuid = ? THEN f.friend_uuid ELSE f.user_uuid END) as target_uuid,
u.username,
f.status,
f.action_uuid,
EXISTS(SELECT 1 FROM server_online so WHERE so.uuid = u.uuid) as online,
(SELECT gs.name FROM server_online so JOIN game_servers gs ON gs.id = so.server_id WHERE so.uuid = u.uuid LIMIT 1) as playing_on
FROM friendships f
JOIN users u ON u.uuid = (CASE WHEN f.user_uuid = ? THEN f.friend_uuid ELSE f.user_uuid END)
WHERE f.user_uuid = ? OR f.friend_uuid = ?
ORDER BY online DESC, u.username ASC",
)
.bind(&auth.uuid)
.bind(&auth.uuid)
.bind(&auth.uuid)
.bind(&auth.uuid)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(|(uuid, username, status, action_uuid, online, playing_on)| {
let final_status = if status == "accepted" {
"accepted".to_string()
} else if action_uuid == auth.uuid {
"pending_outgoing".to_string()
} else {
"pending_incoming".to_string()
};
FriendInfo {
uuid,
username,
status: final_status,
online,
playing_on,
last_seen: None,
}
})
.collect();
Ok(Json(list))
}
#[derive(Deserialize)]
pub struct FriendRequestPayload {
pub username: Option<String>,
pub friend_username: Option<String>,
}
pub async fn send_friend_request(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<FriendRequestPayload>,
) -> AppResult<Json<Value>> {
let name = payload
.username
.as_deref()
.or(payload.friend_username.as_deref())
.ok_or_else(|| AppError::bad_request("Username is required"))?
.trim();
let target: Option<(String, String)> = sqlx::query_as(
"SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE",
)
.bind(name)
.fetch_optional(&state.db)
.await?;
let (target_uuid, target_name) = target.ok_or_else(|| AppError::not_found("Player not found"))?;
if target_uuid == auth.uuid {
return Err(AppError::bad_request("You cannot friend yourself"));
}
let now = chrono::Utc::now().to_rfc3339();
// Check existing
let existing: Option<(i64, String, String)> = sqlx::query_as(
"SELECT id, status, action_uuid FROM friendships
WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)",
)
.bind(&auth.uuid)
.bind(&target_uuid)
.bind(&target_uuid)
.bind(&auth.uuid)
.fetch_optional(&state.db)
.await?;
if let Some((_id, status, action)) = existing {
if status == "accepted" {
return Err(AppError::bad_request("Already friends with this player"));
}
if status == "pending" && action != auth.uuid {
// Reciprocal request -> Auto-accept!
sqlx::query("UPDATE friendships SET status = 'accepted', action_uuid = ?, updated_at = ? WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)")
.bind(&auth.uuid)
.bind(&now)
.bind(&auth.uuid)
.bind(&target_uuid)
.bind(&target_uuid)
.bind(&auth.uuid)
.execute(&state.db)
.await?;
return Ok(Json(serde_json::json!({ "ok": true, "status": "accepted", "friend": target_name })));
}
return Err(AppError::bad_request("Friend request is already pending"));
}
sqlx::query(
"INSERT INTO friendships (user_uuid, friend_uuid, status, action_uuid, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?, ?)",
)
.bind(&auth.uuid)
.bind(&target_uuid)
.bind(&auth.uuid)
.bind(&now)
.bind(&now)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true, "status": "pending_outgoing", "friend": target_name })))
}
pub async fn accept_friend_request(
auth: AuthUser,
Path(target_uuid): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let now = chrono::Utc::now().to_rfc3339();
let res = sqlx::query(
"UPDATE friendships SET status = 'accepted', updated_at = ?
WHERE ((user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?))
AND status = 'pending' AND action_uuid <> ?",
)
.bind(&now)
.bind(&auth.uuid)
.bind(&target_uuid)
.bind(&target_uuid)
.bind(&auth.uuid)
.bind(&auth.uuid)
.execute(&state.db)
.await?;
if res.rows_affected() == 0 {
return Err(AppError::bad_request("No pending request from this user found"));
}
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn remove_friend(
auth: AuthUser,
Path(target_uuid): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query(
"DELETE FROM friendships
WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)",
)
.bind(&auth.uuid)
.bind(&target_uuid)
.bind(&target_uuid)
.bind(&auth.uuid)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
#[derive(Deserialize)]
pub struct RespondFriendPayload {
pub target_uuid: Option<String>,
pub friend_uuid: Option<String>,
pub accept: bool,
}
pub async fn respond_friend_request(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<RespondFriendPayload>,
) -> AppResult<Json<Value>> {
let target = payload
.target_uuid
.or(payload.friend_uuid)
.ok_or_else(|| AppError::bad_request("Friend UUID is required"))?;
if payload.accept {
accept_friend_request(auth, Path(target), State(state)).await
} else {
remove_friend(auth, Path(target), State(state)).await
}
}
// ---------------------------------------------------------------------------
// Direct Messaging (DMs)
// ---------------------------------------------------------------------------
pub async fn get_direct_messages(
auth: AuthUser,
Path(target_uuid): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<DirectMessage>>> {
let rows: Vec<(i64, String, String, String, String, bool, String)> = sqlx::query_as(
"SELECT id, sender_uuid, sender_name, recipient_uuid, content, is_read, created_at
FROM direct_messages
WHERE (sender_uuid = ? AND recipient_uuid = ?) OR (sender_uuid = ? AND recipient_uuid = ?)
ORDER BY id ASC LIMIT 100",
)
.bind(&auth.uuid)
.bind(&target_uuid)
.bind(&target_uuid)
.bind(&auth.uuid)
.fetch_all(&state.db)
.await?;
// Mark unread messages sent to me as read
sqlx::query(
"UPDATE direct_messages SET is_read = 1
WHERE recipient_uuid = ? AND sender_uuid = ? AND is_read = 0",
)
.bind(&auth.uuid)
.bind(&target_uuid)
.execute(&state.db)
.await?;
let list = rows
.into_iter()
.map(|(id, suuid, sname, ruuid, content, is_read, created)| DirectMessage {
id,
sender_uuid: suuid,
sender_name: sname,
recipient_uuid: ruuid,
content,
is_read,
created_at: created,
})
.collect();
Ok(Json(list))
}
#[derive(Deserialize)]
pub struct SendMessagePayload {
pub content: String,
}
pub async fn send_direct_message(
auth: AuthUser,
Path(target_uuid): Path<String>,
State(state): State<AppState>,
Json(payload): Json<SendMessagePayload>,
) -> AppResult<Json<DirectMessage>> {
let content = payload.content.trim();
if content.is_empty() {
return Err(AppError::bad_request("Message cannot be empty"));
}
let now = chrono::Utc::now().to_rfc3339();
let id: i64 = sqlx::query_scalar(
"INSERT INTO direct_messages (sender_uuid, sender_name, recipient_uuid, content, is_read, created_at)
VALUES (?, ?, ?, ?, 0, ?)
RETURNING id",
)
.bind(&auth.uuid)
.bind(&auth.username)
.bind(&target_uuid)
.bind(content)
.bind(&now)
.fetch_one(&state.db)
.await?;
Ok(Json(DirectMessage {
id,
sender_uuid: auth.uuid.clone(),
sender_name: auth.username.clone(),
recipient_uuid: target_uuid,
content: content.to_string(),
is_read: false,
created_at: now,
}))
}
// ---------------------------------------------------------------------------
// Game Invites
// ---------------------------------------------------------------------------
pub async fn list_my_game_invites(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<GameInvite>>> {
let rows: Vec<(
String,
String,
String,
String,
String,
String,
Option<i64>,
Option<String>,
String,
String,
String,
)> = sqlx::query_as(
"SELECT gi.id, gi.sender_uuid, gi.sender_name, gi.recipient_uuid,
gi.instance_id, i.name as instance_name,
gi.server_id, gs.name as server_name,
gi.status, gi.created_at, gi.expires_at
FROM game_invites gi
JOIN instances i ON i.id = gi.instance_id
LEFT JOIN game_servers gs ON gs.id = gi.server_id
WHERE gi.recipient_uuid = ? AND gi.status = 'pending' AND gi.expires_at > ?
ORDER BY gi.created_at DESC",
)
.bind(&auth.uuid)
.bind(chrono::Utc::now().to_rfc3339())
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(
|(id, suuid, sname, ruuid, iid, iname, sid, sname_opt, status, cat, eat)| GameInvite {
id,
sender_uuid: suuid,
sender_name: sname,
recipient_uuid: ruuid,
instance_id: iid,
instance_name: iname,
server_id: sid,
server_name: sname_opt,
status,
created_at: cat,
expires_at: eat,
},
)
.collect();
Ok(Json(list))
}
#[derive(Deserialize)]
pub struct SendInvitePayload {
pub recipient_uuid: String,
pub instance_id: String,
pub server_id: Option<i64>,
}
pub async fn send_game_invite(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<SendInvitePayload>,
) -> AppResult<Json<Value>> {
let invite_id = format!("inv_{}", uuid::Uuid::new_v4().simple());
let now = chrono::Utc::now();
let expires = now + chrono::Duration::minutes(30);
sqlx::query(
"INSERT INTO game_invites (id, sender_uuid, sender_name, recipient_uuid, instance_id, server_id, status, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
)
.bind(&invite_id)
.bind(&auth.uuid)
.bind(&auth.username)
.bind(payload.recipient_uuid)
.bind(payload.instance_id)
.bind(payload.server_id)
.bind(now.to_rfc3339())
.bind(expires.to_rfc3339())
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "id": invite_id, "ok": true })))
}
#[derive(Deserialize)]
pub struct RespondInvitePayload {
pub action: String, // "accept" or "decline"
}
pub async fn respond_game_invite(
auth: AuthUser,
Path(invite_id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<RespondInvitePayload>,
) -> AppResult<Json<Value>> {
let status = if payload.action == "accept" {
"accepted"
} else {
"declined"
};
sqlx::query(
"UPDATE game_invites SET status = ? WHERE id = ? AND recipient_uuid = ?",
)
.bind(status)
.bind(&invite_id)
.bind(&auth.uuid)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true, "status": status })))
}
// ---------------------------------------------------------------------------
// Viewable Player Profiles
// ---------------------------------------------------------------------------
pub async fn get_player_profile(
Path(uuid): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<UserProfileView>> {
let user_row: Option<(String, String)> = sqlx::query_as(
"SELECT uuid, username FROM users WHERE uuid = ?",
)
.bind(&uuid)
.fetch_optional(&state.db)
.await?;
let (puuid, username) = user_row.ok_or_else(|| AppError::not_found("Player profile not found"))?;
let prof_row: Option<(String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT bio, banner_url, custom_badge, featured_achievement_id FROM user_profiles WHERE uuid = ?",
)
.bind(&puuid)
.fetch_optional(&state.db)
.await?;
let (bio, banner_url, custom_badge, feat_ach_id) = prof_row.unwrap_or((String::new(), None, None, None));
// Levels
let levels = crate::routes::leveling::get_user_levels_by_uuid(&state, &puuid).await?;
// Featured achievement if set
let featured_achievement = if let Some(fid) = feat_ach_id {
let ach_list = crate::routes::achievements::get_achievements_for_user(&state, &puuid, false).await?;
ach_list.into_iter().find(|a| a.id == fid)
} else {
None
};
// Unlocked achievements count
let achievements_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?",
)
.bind(&puuid)
.fetch_one(&state.db)
.await?;
// Recent posts
let post_rows: Vec<(i64, String, String, String, Option<String>, i64, String)> = sqlx::query_as(
"SELECT id, user_uuid, author_name, content, image_url, likes_count, created_at
FROM user_posts WHERE user_uuid = ? ORDER BY id DESC LIMIT 10",
)
.bind(&puuid)
.fetch_all(&state.db)
.await?;
let posts = post_rows
.into_iter()
.map(|(id, u_uuid, aname, content, img, likes, cat)| UserPost {
id,
user_uuid: u_uuid,
author_name: aname,
content,
image_url: img,
likes_count: likes,
created_at: cat,
})
.collect();
Ok(Json(UserProfileView {
uuid: puuid,
username,
bio,
banner_url,
custom_badge,
title: levels.title,
global_level: levels.global_level,
global_xp: levels.global_xp,
server_levels: levels.server_levels,
featured_achievement,
achievements_count: achievements_count as usize,
posts,
}))
}
#[derive(Deserialize)]
pub struct UpdateProfilePayload {
pub bio: Option<String>,
pub banner_url: Option<String>,
pub custom_badge: Option<String>,
pub featured_achievement_id: Option<String>,
}
pub async fn update_my_profile(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<UpdateProfilePayload>,
) -> AppResult<Json<Value>> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO user_profiles (uuid, bio, banner_url, custom_badge, featured_achievement_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (uuid) DO UPDATE SET
bio = COALESCE(?, bio),
banner_url = COALESCE(?, banner_url),
custom_badge = COALESCE(?, custom_badge),
featured_achievement_id = COALESCE(?, featured_achievement_id),
updated_at = excluded.updated_at",
)
.bind(&auth.uuid)
.bind(payload.bio.clone().unwrap_or_default())
.bind(payload.banner_url.clone())
.bind(payload.custom_badge.clone())
.bind(payload.featured_achievement_id.clone())
.bind(&now)
.bind(payload.bio)
.bind(payload.banner_url)
.bind(payload.custom_badge)
.bind(payload.featured_achievement_id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// Social Posts Feed
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct CreatePostInput {
pub content: String,
pub image_url: Option<String>,
}
pub async fn create_user_post(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreatePostInput>,
) -> AppResult<Json<UserPost>> {
let content = payload.content.trim();
if content.is_empty() {
return Err(AppError::bad_request("Post content cannot be empty"));
}
let now = chrono::Utc::now().to_rfc3339();
let id: i64 = sqlx::query_scalar(
"INSERT INTO user_posts (user_uuid, author_name, content, image_url, likes_count, created_at)
VALUES (?, ?, ?, ?, 0, ?)
RETURNING id",
)
.bind(&auth.uuid)
.bind(&auth.username)
.bind(content)
.bind(payload.image_url.as_deref())
.bind(&now)
.fetch_one(&state.db)
.await?;
Ok(Json(UserPost {
id,
user_uuid: auth.uuid.clone(),
author_name: auth.username.clone(),
content: content.to_string(),
image_url: payload.image_url,
likes_count: 0,
created_at: now,
}))
}
pub async fn delete_user_post(
auth: AuthUser,
Path(post_id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM user_posts WHERE id = ? AND user_uuid = ?")
.bind(post_id)
.bind(&auth.uuid)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn like_user_post(
_auth: AuthUser,
Path(post_id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?")
.bind(post_id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}