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

+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}")) }
})))
}