feat: complete in-game commands, GUIs, admin panel fixes, and launcher enhancements

This commit is contained in:
scoped committed 2026-09-29 12:14:16 -04:00
1 parent a9ff92e1d1
commit 752f0cc43d
34 files changed
+4170 -181

No files matched your search

+426 -13
View File
@@ -1,6 +1,6 @@
//! Guilds system, in-game relay, roles, posts, and land-claiming.
use crate::auth::AuthUser;
use crate::auth::{AdminUser, AuthUser};
use crate::error::{AppError, AppResult};
use crate::routes::servers::GameServer;
use crate::state::AppState;
@@ -710,10 +710,11 @@ pub async fn unclaim_by_id(
#[derive(Deserialize)]
pub struct ChunkGridQuery {
pub server_id: i64,
pub server_id: Option<i64>,
pub instance_id: Option<String>,
pub dimension: Option<String>,
pub center_x: i32,
pub center_z: i32,
pub center_x: Option<i32>,
pub center_z: Option<i32>,
pub radius: Option<i32>,
}
@@ -723,23 +724,31 @@ pub async fn get_chunk_grid(
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 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, i64, String, i32, i32, String, String)> = sqlx::query_as(
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 gc.server_id = ? AND gc.dimension = ?
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 500",
LIMIT 1000",
)
.bind(query.server_id)
.bind(query.server_id)
.bind(&inst_id)
.bind(&inst_id)
.bind(&dim)
.bind(min_x)
.bind(max_x)
@@ -756,7 +765,7 @@ pub async fn get_chunk_grid(
guild_id: gid,
guild_name: gname,
guild_tag: gtag,
server_id: sid,
server_id: sid.unwrap_or(0),
dimension: gdim,
chunk_x: cx,
chunk_z: cz,
@@ -769,6 +778,106 @@ pub async fn get_chunk_grid(
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
// ---------------------------------------------------------------------------
@@ -827,3 +936,307 @@ pub async fn server_check_chunk(
"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 guild_id, role FROM guild_members WHERE uuid = ?",
)
.bind(&payload.uuid)
.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)
.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 guild_id, role FROM guild_members WHERE uuid = ?",
)
.bind(&payload.uuid)
.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 WHERE uuid = ?)")
.bind(&payload.uuid)
.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(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 guild_id, role FROM guild_members WHERE uuid = ?",
)
.bind(&payload.uuid)
.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 })))
}