feat: add global & server leveling, quests, achievements, guilds land-claiming, social & toggleable configs
This commit is contained in:
1 parent
4b79f69c20
commit
b4cef85b1f
40 files changed
+10261
-27
No files matched your search
@@ -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}")) }
|
||||
})))
|
||||
}
|
||||
Reference in new issue
Block a user