Files
SCOPENET-MC/panel/server/src/routes/guilds.rs
T

1259 lines
37 KiB
Rust

//! Guilds system, in-game relay, roles, posts, and land-claiming.
use crate::auth::{AdminUser, 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, COALESCE(server_id, 0), 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)
}
pub async fn get_guild_members(Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Vec<GuildMember>>> {
Ok(Json(fetch_guild_detail(&state, &id).await?.members))
}
pub async fn get_guild_posts(Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Vec<GuildPost>>> {
Ok(Json(fetch_guild_detail(&state, &id).await?.posts))
}
#[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?;
crate::routes::leveling::grant_rewards(&mut tx, &auth.uuid, &now).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, "guild_id": id, "author_uuid": auth.uuid,
"author_name": auth.username, "title": payload.title, "content": payload.content, "created_at": now })))
}
// ---------------------------------------------------------------------------
// 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 server_id = payload.server_id.ok_or_else(|| AppError::bad_request("Select a game server for this claim"))?;
let matches: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM game_servers s JOIN guilds g ON g.instance_id = s.instance_id WHERE s.id = ? AND g.id = ?)")
.bind(server_id).bind(&guild_id).fetch_one(&state.db).await?;
if !matches { return Err(AppError::bad_request("Server is not linked to this guild's instance")); }
let dim = payload.dimension.unwrap_or_else(|| "minecraft:overworld".into());
let now = chrono::Utc::now().to_rfc3339();
let res = sqlx::query_scalar::<_, i64>(
"INSERT INTO guild_claims (guild_id, server_id, dimension, chunk_x, chunk_z, claimed_by_uuid, claimed_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id",
)
.bind(&guild_id)
.bind(server_id)
.bind(&dim)
.bind(payload.chunk_x)
.bind(payload.chunk_z)
.bind(&auth.uuid)
.bind(&now)
.fetch_one(&state.db)
.await;
let claim_id = res.map_err(|_| AppError::bad_request("Chunk is already claimed"))?;
// 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?;
let detail = fetch_guild_detail(&state, &guild_id).await?;
Ok(Json(serde_json::to_value(detail.claims.into_iter().find(|c| c.id == claim_id).ok_or_else(|| AppError::not_found("Claim not found"))?)?))
}
/// 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: Option<i64>,
pub instance_id: Option<String>,
pub dimension: Option<String>,
pub center_x: Option<i32>,
pub center_z: Option<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 inst_id = query.instance_id.unwrap_or_default();
let cx = query.center_x.unwrap_or(0);
let cz = query.center_z.unwrap_or(0);
let r = query.radius.unwrap_or(32).clamp(2, 1024);
let min_x = cx - r;
let max_x = cx + r;
let min_z = cz - r;
let max_z = cz + r;
let rows: Vec<(i64, String, String, String, Option<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 (? IS NULL OR gc.server_id = ?)
AND (? = '' OR g.instance_id = ?)
AND gc.dimension = ?
AND gc.chunk_x BETWEEN ? AND ?
AND gc.chunk_z BETWEEN ? AND ?
LIMIT 1000",
)
.bind(query.server_id)
.bind(query.server_id)
.bind(&inst_id)
.bind(&inst_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.unwrap_or(0),
dimension: gdim,
chunk_x: cx,
chunk_z: cz,
claimed_by_uuid: cby,
claimed_at: cat,
},
)
.collect();
Ok(Json(list))
}
/// Admin list all guilds.
pub async fn admin_list_guilds(
_admin: AdminUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<Guild>>> {
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
ORDER BY g.level DESC, member_count DESC",
)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(
|(
id,
inst_id,
name,
tag,
desc,
motd,
leader,
icon,
banner,
lvl,
xp,
max_c,
created,
members,
claims,
)| Guild {
id,
instance_id: inst_id,
name,
tag,
description: desc,
motd,
leader_uuid: leader,
icon_url: icon,
banner_url: banner,
level: lvl,
xp,
max_claims: max_c,
member_count: members,
claims_count: claims,
created_at: created,
},
)
.collect();
Ok(Json(list))
}
/// Admin delete a guild.
pub async fn admin_delete_guild(
_admin: AdminUser,
Path(id): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM guild_claims WHERE guild_id = ?")
.bind(&id)
.execute(&state.db)
.await?;
sqlx::query("DELETE FROM guild_members WHERE guild_id = ?")
.bind(&id)
.execute(&state.db)
.await?;
sqlx::query("DELETE FROM guild_posts WHERE guild_id = ?")
.bind(&id)
.execute(&state.db)
.await?;
sqlx::query("DELETE FROM guilds WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// 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}")) }
})))
}
#[derive(Deserialize)]
pub struct ServerClaimChunkPayload {
pub uuid: String,
pub dimension: String,
pub chunk_x: i32,
pub chunk_z: i32,
}
pub async fn server_claim_chunk(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerClaimChunkPayload>,
) -> AppResult<Json<Value>> {
let member: Option<(String, String)> = sqlx::query_as(
"SELECT gm.guild_id, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?",
)
.bind(&payload.uuid)
.bind(&server.instance_id)
.fetch_optional(&state.db)
.await?;
let Some((guild_id, _role)) = member else {
return Err(AppError::bad_request("You must be in a guild to claim land. Create one with /guild create <name> <tag>"));
};
let max_claims: i64 = sqlx::query_scalar("SELECT max_claims FROM guilds WHERE id = ?")
.bind(&guild_id)
.fetch_one(&state.db)
.await
.unwrap_or(16);
let current_claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?")
.bind(&guild_id)
.fetch_one(&state.db)
.await
.unwrap_or(0);
if current_claims >= max_claims {
return Err(AppError::bad_request(format!("Guild reached its max claim limit of {max_claims} chunks")));
}
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(server.id)
.bind(&payload.dimension)
.bind(payload.chunk_x)
.bind(payload.chunk_z)
.bind(&payload.uuid)
.bind(&now)
.execute(&state.db)
.await;
if let Err(e) = res {
tracing::warn!("server claim chunk failed: {e}");
return Err(AppError::bad_request("Chunk is already claimed by another guild"));
}
let guild_name: String = sqlx::query_scalar("SELECT name FROM guilds WHERE id = ?")
.bind(&guild_id)
.fetch_one(&state.db)
.await
.unwrap_or_else(|_| "Guild".into());
Ok(Json(serde_json::json!({
"ok": true,
"guild_id": guild_id,
"guild_name": guild_name,
"chunk_x": payload.chunk_x,
"chunk_z": payload.chunk_z
})))
}
pub async fn server_unclaim_chunk(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerClaimChunkPayload>,
) -> AppResult<Json<Value>> {
let claim: Option<(i64, String)> = sqlx::query_as(
"SELECT id, guild_id FROM guild_claims WHERE server_id = ? AND dimension = ? AND chunk_x = ? AND chunk_z = ?",
)
.bind(server.id)
.bind(&payload.dimension)
.bind(payload.chunk_x)
.bind(payload.chunk_z)
.fetch_optional(&state.db)
.await?;
let Some((claim_id, guild_id)) = claim else {
return Err(AppError::not_found("This chunk is not claimed"));
};
let member: Option<(String,)> = sqlx::query_as(
"SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?",
)
.bind(&guild_id)
.bind(&payload.uuid)
.bind(&server.instance_id)
.fetch_optional(&state.db)
.await?;
if member.is_none() {
return Err(AppError::forbidden("You cannot unclaim land belonging to another 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 ServerGuildPlayerQuery {
pub uuid: String,
}
pub async fn server_get_player_guild(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerGuildPlayerQuery>,
) -> AppResult<Json<Value>> {
let member_opt: Option<(String, String)> = sqlx::query_as(
"SELECT gm.guild_id, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?",
)
.bind(&payload.uuid)
.bind(&server.instance_id)
.fetch_optional(&state.db)
.await?;
let Some((guild_id, player_role)) = member_opt else {
return Ok(Json(serde_json::json!({ "in_guild": false })));
};
let guild_opt: Option<(String, String, String, String, String, i64, i64, i64)> = sqlx::query_as(
"SELECT name, tag, description, motd, leader_uuid, level, xp, max_claims FROM guilds WHERE id = ?",
)
.bind(&guild_id)
.fetch_optional(&state.db)
.await?;
let Some((name, tag, desc, motd, leader_uuid, level, xp, max_claims)) = guild_opt else {
return Ok(Json(serde_json::json!({ "in_guild": false })));
};
let member_rows: Vec<(String, String, String)> = sqlx::query_as(
"SELECT uuid, name, role FROM guild_members WHERE guild_id = ?",
)
.bind(&guild_id)
.fetch_all(&state.db)
.await?;
let claims_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ?")
.bind(&guild_id)
.fetch_one(&state.db)
.await
.unwrap_or(0);
let members_val: Vec<Value> = member_rows
.into_iter()
.map(|(u, n, r)| serde_json::json!({ "uuid": u, "name": n, "role": r }))
.collect();
Ok(Json(serde_json::json!({
"in_guild": true,
"guild": {
"id": guild_id,
"name": name,
"tag": tag,
"description": desc,
"motd": motd,
"leader_uuid": leader_uuid,
"role": player_role,
"level": level,
"xp": xp,
"claims_count": claims_count,
"max_claims": max_claims,
"members": members_val
}
})))
}
#[derive(Deserialize)]
pub struct ServerCreateGuildPayload {
pub uuid: String,
pub username: String,
pub name: String,
pub tag: String,
}
pub async fn server_create_guild(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerCreateGuildPayload>,
) -> AppResult<Json<Value>> {
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 between 3 and 32 characters"));
}
if tag.len() < 2 || tag.len() > 6 {
return Err(AppError::bad_request("Guild tag must be between 2 and 6 characters"));
}
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(&payload.uuid)
.bind(&server.instance_id)
.fetch_one(&state.db)
.await?;
if in_guild {
return Err(AppError::bad_request("You are already in a guild. Leave your current guild first"));
}
let guild_id = format!("guild_{}", uuid::Uuid::new_v4().simple());
let now = chrono::Utc::now().to_rfc3339();
let mut tx = state.db.begin().await?;
let res = sqlx::query(
"INSERT INTO guilds (id, instance_id, name, tag, description, motd, leader_uuid, created_at)
VALUES (?, ?, ?, ?, '', 'Welcome to the guild!', ?, ?)",
)
.bind(&guild_id)
.bind(&server.instance_id)
.bind(name)
.bind(tag)
.bind(&payload.uuid)
.bind(&now)
.execute(&mut *tx)
.await;
if let Err(e) = res {
tracing::warn!("server_create_guild failed: {e}");
return Err(AppError::bad_request("A guild with that name or tag already exists"));
}
sqlx::query(
"INSERT INTO guild_members (guild_id, uuid, name, role, joined_at)
VALUES (?, ?, ?, 'leader', ?)",
)
.bind(&guild_id)
.bind(&payload.uuid)
.bind(&payload.username)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({
"ok": true,
"guild_id": guild_id,
"name": name,
"tag": tag
})))
}
#[derive(Deserialize)]
pub struct ServerGuildLeavePayload {
pub uuid: String,
}
pub async fn server_guild_leave(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerGuildLeavePayload>,
) -> AppResult<Json<Value>> {
let member_opt: Option<(String, String)> = sqlx::query_as(
"SELECT gm.guild_id, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?",
)
.bind(&payload.uuid)
.bind(&server.instance_id)
.fetch_optional(&state.db)
.await?;
let Some((guild_id, role)) = member_opt else {
return Err(AppError::bad_request("You are not in a guild"));
};
if role == "leader" {
let member_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_members WHERE guild_id = ?")
.bind(&guild_id)
.fetch_one(&state.db)
.await
.unwrap_or(1);
if member_count > 1 {
return Err(AppError::bad_request("Guild leader cannot leave without transferring leadership"));
} else {
sqlx::query("DELETE FROM guild_claims WHERE guild_id = ?").bind(&guild_id).execute(&state.db).await?;
sqlx::query("DELETE FROM guild_members WHERE guild_id = ?").bind(&guild_id).execute(&state.db).await?;
sqlx::query("DELETE FROM guilds WHERE id = ?").bind(&guild_id).execute(&state.db).await?;
return Ok(Json(serde_json::json!({ "ok": true, "disbanded": true })));
}
}
sqlx::query("DELETE FROM guild_members WHERE guild_id = ? AND uuid = ?")
.bind(&guild_id)
.bind(&payload.uuid)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true, "disbanded": false })))
}