//! 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, } /// List guilds for an instance. pub async fn list_guilds( Query(query): Query, State(state): State, ) -> AppResult>> { let instance_id = query.instance_id.unwrap_or_default(); let rows: Vec<( String, String, String, String, String, String, String, Option, Option, 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, State(state): State, ) -> AppResult>> { let instance_id = query.instance_id.unwrap_or_default(); let guild_id: Option = 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, pub posts: Vec, pub claims: Vec, } async fn fetch_guild_detail(state: &AppState, guild_id: &str) -> AppResult { let row: Option<( String, String, String, String, String, String, String, Option, Option, 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 = 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 = 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 = 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, State(state): State, ) -> AppResult> { fetch_guild_detail(&state, &id).await.map(Json) } pub async fn get_guild_members(Path(id): Path, State(state): State) -> AppResult>> { Ok(Json(fetch_guild_detail(&state, &id).await?.members)) } pub async fn get_guild_posts(Path(id): Path, State(state): State) -> AppResult>> { 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, pub motd: Option, pub icon_url: Option, pub banner_url: Option, } /// Create a new Guild. pub async fn create_guild( auth: AuthUser, State(state): State, Json(payload): Json, ) -> AppResult> { 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, pub motd: Option, pub icon_url: Option, pub banner_url: Option, } /// Update guild info. pub async fn update_guild( auth: AuthUser, Path(id): Path, State(state): State, Json(payload): Json, ) -> AppResult> { let role: Option = 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, State(state): State, Json(payload): Json, ) -> AppResult> { let role: Option = 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, ) -> AppResult> { let caller_role: Option = 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, State(state): State, Json(payload): Json, ) -> AppResult> { 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, pub instance_id: Option, pub dimension: Option, 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, State(state): State, Json(payload): Json, ) -> AppResult> { let role: Option = 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, State(state): State, Json(payload): Json, ) -> AppResult> { let role: Option = 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, State(state): State, ) -> AppResult> { 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 = 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, pub instance_id: Option, pub dimension: Option, pub center_x: Option, pub center_z: Option, pub radius: Option, } /// Returns chunk claims in a bounding box centered around (center_x, center_z) chunks. pub async fn get_chunk_grid( Query(query): Query, State(state): State, ) -> AppResult>> { 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, 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, ) -> AppResult>> { let rows: Vec<( String, String, String, String, String, String, String, Option, Option, 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, State(state): State, ) -> AppResult> { 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, Json(payload): Json, ) -> AppResult> { 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, Json(payload): Json, ) -> AppResult> { 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 ")); }; 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, Json(payload): Json, ) -> AppResult> { 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, Json(payload): Json, ) -> AppResult> { 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 = 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, Json(payload): Json, ) -> AppResult> { 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, Json(payload): Json, ) -> AppResult> { 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 }))) }