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

+33 -12
View File
@@ -78,12 +78,15 @@ pub async fn get_achievements_for_user(
title,
description: desc,
category: cat,
icon_frame: frame,
icon_frame: frame.clone(),
frame_type: Some(frame),
icon_item: item,
icon_bg: bg,
icon_border: border,
xp_reward: xp,
secret,
stat_type: None,
target_count: None,
unlocked,
unlocked_at,
})
@@ -105,6 +108,8 @@ pub struct AdminAchievementRow {
pub requirement_type: String,
pub requirement_key: String,
pub requirement_value: i64,
pub stat_type: String,
pub target_count: i64,
pub total_unlocked: i64,
}
@@ -166,18 +171,23 @@ pub async fn admin_list_achievements(
title,
description: desc,
category: cat,
icon_frame: frame,
icon_frame: frame.clone(),
frame_type: Some(frame),
icon_item: item,
icon_bg: bg,
icon_border: border,
xp_reward: xp,
secret,
stat_type: Some(req_key.clone()),
target_count: Some(req_val),
unlocked: false,
unlocked_at: None,
},
requirement_type: req_type,
requirement_key: req_key,
requirement_key: req_key.clone(),
requirement_value: req_val,
stat_type: req_key,
target_count: req_val,
total_unlocked,
}
},
@@ -193,13 +203,17 @@ pub struct AchievementPayload {
pub title: String,
pub description: String,
pub category: String,
#[serde(alias = "frame_type")]
pub icon_frame: String,
pub icon_item: String,
pub icon_bg: String,
pub icon_border: String,
pub requirement_type: String,
pub requirement_key: String,
pub requirement_value: i64,
#[serde(default)]
pub requirement_type: Option<String>,
#[serde(default, alias = "stat_type")]
pub requirement_key: Option<String>,
#[serde(default, alias = "target_count")]
pub requirement_value: Option<i64>,
pub xp_reward: i64,
pub secret: Option<bool>,
}
@@ -215,6 +229,9 @@ pub async fn admin_create_achievement(
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| format!("ach_{}", uuid::Uuid::new_v4().simple()));
let now = chrono::Utc::now().to_rfc3339();
let req_type = payload.requirement_type.unwrap_or_else(|| "stat".into());
let req_key = payload.requirement_key.unwrap_or_else(|| "blocks_broken".into());
let req_val = payload.requirement_value.unwrap_or(50);
sqlx::query(
"INSERT INTO achievements (id, title, description, category, icon_frame, icon_item, icon_bg, icon_border, requirement_type, requirement_key, requirement_value, xp_reward, secret, created_at)
@@ -228,9 +245,9 @@ pub async fn admin_create_achievement(
.bind(payload.icon_item)
.bind(payload.icon_bg)
.bind(payload.icon_border)
.bind(payload.requirement_type)
.bind(payload.requirement_key)
.bind(payload.requirement_value)
.bind(req_type)
.bind(req_key)
.bind(req_val)
.bind(payload.xp_reward)
.bind(payload.secret.unwrap_or(false))
.bind(&now)
@@ -247,6 +264,10 @@ pub async fn admin_update_achievement(
State(state): State<AppState>,
Json(payload): Json<AchievementPayload>,
) -> AppResult<Json<Value>> {
let req_type = payload.requirement_type.unwrap_or_else(|| "stat".into());
let req_key = payload.requirement_key.unwrap_or_else(|| "blocks_broken".into());
let req_val = payload.requirement_value.unwrap_or(50);
sqlx::query(
"UPDATE achievements SET
title = ?,
@@ -270,9 +291,9 @@ pub async fn admin_update_achievement(
.bind(payload.icon_item)
.bind(payload.icon_bg)
.bind(payload.icon_border)
.bind(payload.requirement_type)
.bind(payload.requirement_key)
.bind(payload.requirement_value)
.bind(req_type)
.bind(req_key)
.bind(req_val)
.bind(payload.xp_reward)
.bind(payload.secret)
.bind(&id)
+483
View File
@@ -0,0 +1,483 @@
//! Per-server economy system, marketplace, transactions, and baltop.
use crate::auth::AuthUser;
use crate::error::{AppError, AppResult};
use crate::routes::servers::GameServer;
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::Json;
use scopenet_shared::{BaltopEntry, EconomyTransaction, MarketListing, ServerEconomyBalance};
use serde::Deserialize;
use serde_json::Value;
// ---------------------------------------------------------------------------
// Launcher & Public Economy API
// ---------------------------------------------------------------------------
/// Get current user's economy balances across all connected game servers.
pub async fn get_my_balances(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<ServerEconomyBalance>>> {
let rows: Vec<(i64, String, f64)> = sqlx::query_as(
"SELECT se.server_id, gs.name, se.balance
FROM server_economy se
JOIN game_servers gs ON gs.id = se.server_id
WHERE se.uuid = ?
ORDER BY se.balance DESC",
)
.bind(&auth.uuid)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(|(sid, sname, bal)| ServerEconomyBalance {
server_id: sid,
server_name: sname,
balance: bal,
currency_symbol: "$".into(),
})
.collect();
Ok(Json(list))
}
/// Get current user's recent transactions across all servers.
pub async fn get_my_transactions(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<EconomyTransaction>>> {
let rows: Vec<(i64, i64, String, String, String, String, String, f64, String, String)> = sqlx::query_as(
"SELECT et.id, et.server_id, gs.name, et.from_uuid, et.from_name, et.to_uuid, et.to_name, et.amount, et.description, et.created_at
FROM economy_transactions et
JOIN game_servers gs ON gs.id = et.server_id
WHERE et.from_uuid = ? OR et.to_uuid = ?
ORDER BY et.id DESC
LIMIT 50",
)
.bind(&auth.uuid)
.bind(&auth.uuid)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(|(id, sid, sname, fuuid, fname, tuuid, tname, amount, desc, created)| EconomyTransaction {
id,
server_id: sid,
server_name: Some(sname),
from_uuid: fuuid,
from_name: fname,
to_uuid: tuuid,
to_name: tname,
amount,
description: desc,
created_at: created,
})
.collect();
Ok(Json(list))
}
/// Get top richest players for a specific server.
pub async fn get_server_baltop(
Path(server_id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<BaltopEntry>>> {
let rows: Vec<(String, String, f64)> = sqlx::query_as(
"SELECT uuid, username, balance
FROM server_economy
WHERE server_id = ?
ORDER BY balance DESC
LIMIT 25",
)
.bind(server_id)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.enumerate()
.map(|(i, (uuid, username, balance))| BaltopEntry {
rank: i + 1,
uuid,
username,
balance,
})
.collect();
Ok(Json(list))
}
/// Get active marketplace listings for a server.
pub async fn get_server_market(
Path(server_id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<MarketListing>>> {
let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String)> = sqlx::query_as(
"SELECT id, server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at
FROM server_market
WHERE server_id = ?
ORDER BY id DESC
LIMIT 100",
)
.bind(server_id)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.map(|(id, sid, suuid, sname, iid, iname, amt, price, created)| MarketListing {
id,
server_id: sid,
seller_uuid: suuid,
seller_name: sname,
item_id: iid,
item_name: iname,
amount: amt,
price,
created_at: created,
})
.collect();
Ok(Json(list))
}
// ---------------------------------------------------------------------------
// Server Integration Relay Endpoints
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct ServerBalanceQuery {
pub uuid: String,
pub username: Option<String>,
}
pub async fn server_get_balance(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerBalanceQuery>,
) -> AppResult<Json<Value>> {
let now = chrono::Utc::now().to_rfc3339();
let username = payload.username.unwrap_or_else(|| "Player".into());
let bal_opt: Option<f64> = sqlx::query_scalar(
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
)
.bind(server.id)
.bind(&payload.uuid)
.fetch_optional(&state.db)
.await?;
let balance = match bal_opt {
Some(b) => b,
None => {
let starting_balance = 1000.0;
sqlx::query(
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(server_id, uuid) DO UPDATE SET username = excluded.username",
)
.bind(server.id)
.bind(&payload.uuid)
.bind(&username)
.bind(starting_balance)
.bind(&now)
.execute(&state.db)
.await?;
starting_balance
}
};
Ok(Json(serde_json::json!({
"server_id": server.id,
"uuid": payload.uuid,
"balance": balance,
"currency_symbol": "$"
})))
}
#[derive(Deserialize)]
pub struct ServerTransferPayload {
pub from_uuid: String,
pub from_name: String,
pub to_uuid: String,
pub to_name: String,
pub amount: f64,
pub description: Option<String>,
}
pub async fn server_transfer(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerTransferPayload>,
) -> AppResult<Json<Value>> {
if payload.amount <= 0.0 {
return Err(AppError::bad_request("Transfer amount must be positive"));
}
let mut tx = state.db.begin().await?;
let now = chrono::Utc::now().to_rfc3339();
// Check sender balance
let from_bal: Option<f64> = sqlx::query_scalar(
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
)
.bind(server.id)
.bind(&payload.from_uuid)
.fetch_optional(&mut *tx)
.await?;
let current_from_bal = from_bal.unwrap_or(0.0);
if current_from_bal < payload.amount {
return Err(AppError::bad_request("Insufficient funds"));
}
let new_from_bal = current_from_bal - payload.amount;
sqlx::query("UPDATE server_economy SET balance = ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
.bind(new_from_bal)
.bind(&now)
.bind(server.id)
.bind(&payload.from_uuid)
.execute(&mut *tx)
.await?;
// Update receiver
sqlx::query(
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (server_id, uuid) DO UPDATE SET
balance = balance + excluded.balance,
username = excluded.username,
updated_at = excluded.updated_at",
)
.bind(server.id)
.bind(&payload.to_uuid)
.bind(&payload.to_name)
.bind(payload.amount)
.bind(&now)
.execute(&mut *tx)
.await?;
let desc = payload.description.unwrap_or_else(|| format!("Payment to {}", payload.to_name));
sqlx::query(
"INSERT INTO economy_transactions (server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(server.id)
.bind(&payload.from_uuid)
.bind(&payload.from_name)
.bind(&payload.to_uuid)
.bind(&payload.to_name)
.bind(payload.amount)
.bind(&desc)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({
"ok": true,
"from_balance": new_from_bal
})))
}
#[derive(Deserialize)]
pub struct ServerSyncBalancePayload {
pub uuid: String,
pub username: String,
pub new_balance: f64,
}
pub async fn server_sync_balance(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<ServerSyncBalancePayload>,
) -> AppResult<Json<Value>> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(server_id, uuid) DO UPDATE SET
balance = excluded.balance,
username = excluded.username,
updated_at = excluded.updated_at",
)
.bind(server.id)
.bind(&payload.uuid)
.bind(&payload.username)
.bind(payload.new_balance)
.bind(&now)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn server_baltop(
GameServer(server): GameServer,
State(state): State<AppState>,
) -> AppResult<Json<Vec<BaltopEntry>>> {
let rows: Vec<(String, String, f64)> = sqlx::query_as(
"SELECT uuid, username, balance
FROM server_economy
WHERE server_id = ?
ORDER BY balance DESC
LIMIT 20",
)
.bind(server.id)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.enumerate()
.map(|(i, (uuid, username, balance))| BaltopEntry {
rank: i + 1,
uuid,
username,
balance,
})
.collect();
Ok(Json(list))
}
#[derive(Deserialize)]
pub struct MarketListPayload {
pub seller_uuid: String,
pub seller_name: String,
pub item_id: String,
pub item_name: String,
pub amount: i32,
pub price: f64,
}
pub async fn server_market_list(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<MarketListPayload>,
) -> AppResult<Json<Value>> {
let now = chrono::Utc::now().to_rfc3339();
let id: i64 = sqlx::query_scalar(
"INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(server.id)
.bind(payload.seller_uuid)
.bind(payload.seller_name)
.bind(payload.item_id)
.bind(payload.item_name)
.bind(payload.amount)
.bind(payload.price)
.bind(&now)
.fetch_one(&state.db)
.await?;
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
}
#[derive(Deserialize)]
pub struct MarketBuyPayload {
pub listing_id: i64,
pub buyer_uuid: String,
pub buyer_name: String,
}
pub async fn server_market_buy(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(payload): Json<MarketBuyPayload>,
) -> AppResult<Json<Value>> {
let listing: Option<(String, String, String, String, i32, f64)> = sqlx::query_as(
"SELECT seller_uuid, seller_name, item_id, item_name, amount, price
FROM server_market
WHERE id = ? AND server_id = ?",
)
.bind(payload.listing_id)
.bind(server.id)
.fetch_optional(&state.db)
.await?;
let Some((seller_uuid, seller_name, item_id, item_name, amount, price)) = listing else {
return Err(AppError::not_found("Listing not found"));
};
let mut tx = state.db.begin().await?;
let now = chrono::Utc::now().to_rfc3339();
// Deduct buyer
let buyer_bal: f64 = sqlx::query_scalar(
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
)
.bind(server.id)
.bind(&payload.buyer_uuid)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(0.0);
if buyer_bal < price {
return Err(AppError::bad_request("Insufficient funds"));
}
sqlx::query("UPDATE server_economy SET balance = balance - ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
.bind(price)
.bind(&now)
.bind(server.id)
.bind(&payload.buyer_uuid)
.execute(&mut *tx)
.await?;
// Pay seller
sqlx::query(
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (server_id, uuid) DO UPDATE SET
balance = balance + excluded.balance,
username = excluded.username,
updated_at = excluded.updated_at",
)
.bind(server.id)
.bind(&seller_uuid)
.bind(&seller_name)
.bind(price)
.bind(&now)
.execute(&mut *tx)
.await?;
// Delete listing
sqlx::query("DELETE FROM server_market WHERE id = ?")
.bind(payload.listing_id)
.execute(&mut *tx)
.await?;
// Log transaction
let desc = format!("Market purchase: {}x {} from {}", amount, item_name, seller_name);
sqlx::query(
"INSERT INTO economy_transactions (server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(server.id)
.bind(&payload.buyer_uuid)
.bind(&payload.buyer_name)
.bind(&seller_uuid)
.bind(&seller_name)
.bind(price)
.bind(&desc)
.bind(&now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({
"ok": true,
"item_id": item_id,
"item_name": item_name,
"amount": amount,
"price": price
})))
}
+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 })))
}
+11 -9
View File
@@ -63,6 +63,7 @@ pub struct LandingBlock {
pub enabled: bool,
pub title: Option<String>,
pub subtitle: Option<String>,
pub options: serde_json::Value,
}
impl Default for LandingBlock {
@@ -73,6 +74,7 @@ impl Default for LandingBlock {
enabled: true,
title: None,
subtitle: None,
options: serde_json::json!({}),
}
}
}
@@ -115,15 +117,15 @@ impl Default for LandingConfig {
hosted_downloads: vec![],
external_download_url: None,
blocks: vec![
LandingBlock { id: "b_hero".into(), block_type: "hero".into(), enabled: true, title: None, subtitle: None },
LandingBlock { id: "b_download".into(), block_type: "download".into(), enabled: true, title: Some("Get the Launcher".into()), subtitle: Some("Available on Windows, macOS, and Linux".into()) },
LandingBlock { id: "b_servers".into(), block_type: "servers".into(), enabled: true, title: Some("Live Servers".into()), subtitle: Some("Check our current status and join the game".into()) },
LandingBlock { id: "b_stats".into(), block_type: "stats".into(), enabled: true, title: Some("Community By Numbers".into()), subtitle: Some("Every block placed, every second played".into()) },
LandingBlock { id: "b_leaderboard".into(), block_type: "leaderboard".into(), enabled: true, title: Some("Top Players".into()), subtitle: Some("The champions of our network".into()) },
LandingBlock { id: "b_instances".into(), block_type: "instances".into(), enabled: true, title: Some("Featured Instances".into()), subtitle: Some("Hand-crafted modpacks ready to launch".into()) },
LandingBlock { id: "b_news".into(), block_type: "news".into(), enabled: true, title: Some("Latest News".into()), subtitle: Some("Updates, events, and patch notes".into()) },
LandingBlock { id: "b_faq".into(), block_type: "faq".into(), enabled: true, title: Some("Frequently Asked Questions".into()), subtitle: Some("Everything you need to know".into()) },
LandingBlock { id: "b_socials".into(), block_type: "socials".into(), enabled: true, title: Some("Join Our Community".into()), subtitle: Some("Connect with thousands of players".into()) },
LandingBlock { id: "b_hero".into(), block_type: "hero".into(), enabled: true, title: None, subtitle: None, options: serde_json::json!({}) },
LandingBlock { id: "b_download".into(), block_type: "download".into(), enabled: true, title: Some("Get the Launcher".into()), subtitle: Some("Available on Windows, macOS, and Linux".into()), options: serde_json::json!({}) },
LandingBlock { id: "b_servers".into(), block_type: "servers".into(), enabled: true, title: Some("Live Servers".into()), subtitle: Some("Check our current status and join the game".into()), options: serde_json::json!({}) },
LandingBlock { id: "b_stats".into(), block_type: "stats".into(), enabled: true, title: Some("Community By Numbers".into()), subtitle: Some("Every block placed, every second played".into()), options: serde_json::json!({}) },
LandingBlock { id: "b_leaderboard".into(), block_type: "leaderboard".into(), enabled: true, title: Some("Top Players".into()), subtitle: Some("The champions of our network".into()), options: serde_json::json!({}) },
LandingBlock { id: "b_instances".into(), block_type: "instances".into(), enabled: true, title: Some("Featured Instances".into()), subtitle: Some("Hand-crafted modpacks ready to launch".into()), options: serde_json::json!({}) },
LandingBlock { id: "b_news".into(), block_type: "news".into(), enabled: true, title: Some("Latest News".into()), subtitle: Some("Updates, events, and patch notes".into()), options: serde_json::json!({}) },
LandingBlock { id: "b_faq".into(), block_type: "faq".into(), enabled: true, title: Some("Frequently Asked Questions".into()), subtitle: Some("Everything you need to know".into()), options: serde_json::json!({}) },
LandingBlock { id: "b_socials".into(), block_type: "socials".into(), enabled: true, title: Some("Join Our Community".into()), subtitle: Some("Connect with thousands of players".into()), options: serde_json::json!({}) },
],
faqs: vec![
FaqItem {
+131 -10
View File
@@ -103,16 +103,56 @@ pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<
})
.collect();
// Query rewards
let rewards_rows: Vec<(i64, i64, String, String, String)> = sqlx::query_as(
"SELECT id, level_req, reward_type, reward_name, reward_data
FROM level_rewards
ORDER BY level_req ASC",
)
.fetch_all(&state.db)
.await
.unwrap_or_default();
let rewards = rewards_rows
.into_iter()
.map(|(id, req, rtype, rname, rdata)| {
let data: Value = serde_json::from_str(&rdata).unwrap_or_default();
let desc = data.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
let icon = data.get("icon").and_then(|v| v.as_str()).map(|s| s.to_string());
scopenet_shared::LevelReward {
id,
level: req,
reward_type: rtype,
reward_value: rname,
description: desc,
icon,
server_id: None,
}
})
.collect();
let rank_opt: Option<i64> = sqlx::query_scalar(
"SELECT COUNT(*) + 1 FROM user_levels WHERE global_xp > ?",
)
.bind(global_xp)
.fetch_optional(&state.db)
.await
.unwrap_or(Some(1));
Ok(UserLevelInfo {
uuid: uuid.to_string(),
global_xp,
current_xp: global_xp,
global_level,
level: global_level,
current_level_xp,
next_level_xp,
progress_pct,
title,
badges,
server_levels,
rank: rank_opt.map(|r| r as usize),
rewards,
})
}
@@ -168,13 +208,32 @@ pub async fn list_rewards(
.into_iter()
.map(|(id, ltype, sid, req, rtype, name, data, created)| {
let parsed_data: Value = serde_json::from_str(&data).unwrap_or_default();
let desc = parsed_data.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let icon = parsed_data.get("icon")
.and_then(|v| v.as_str())
.unwrap_or(match rtype.as_str() {
"title" => "👑",
"profile_badge" | "badge" => "⭐",
"cosmetic" => "✨",
"item" => "🗡️",
_ => "🎁",
})
.to_string();
serde_json::json!({
"id": id,
"level": req,
"level_req": req,
"level_type": ltype,
"server_id": sid,
"level_req": req,
"reward_type": rtype,
"reward_value": name,
"reward_name": name,
"description": if desc.is_empty() { format!("Granted at Level {req}") } else { desc },
"icon": icon,
"reward_data": parsed_data,
"created_at": created
})
@@ -185,12 +244,17 @@ pub async fn list_rewards(
}
#[derive(Deserialize)]
pub struct CreateRewardPayload {
pub level_type: String,
pub struct RewardPayload {
pub id: Option<i64>,
pub level_type: Option<String>,
pub server_id: Option<i64>,
pub level_req: i64,
#[serde(alias = "level")]
pub level_req: Option<i64>,
pub reward_type: String,
pub reward_name: String,
#[serde(alias = "reward_value")]
pub reward_name: Option<String>,
pub description: Option<String>,
pub icon: Option<String>,
pub reward_data: Option<Value>,
}
@@ -198,21 +262,36 @@ pub struct CreateRewardPayload {
pub async fn admin_create_reward(
_admin: AdminUser,
State(state): State<AppState>,
Json(payload): Json<CreateRewardPayload>,
Json(payload): Json<RewardPayload>,
) -> AppResult<Json<Value>> {
let now = chrono::Utc::now().to_rfc3339();
let data_str = serde_json::to_string(&payload.reward_data.unwrap_or_default()).unwrap_or_default();
let level_req = payload.level_req.unwrap_or(2);
let reward_name = payload.reward_name.unwrap_or_else(|| "Reward".into());
let level_type = payload.level_type.unwrap_or_else(|| "global".into());
let mut data_obj = match payload.reward_data {
Some(Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
if let Some(desc) = payload.description {
data_obj.insert("description".into(), Value::String(desc));
}
if let Some(ico) = payload.icon {
data_obj.insert("icon".into(), Value::String(ico));
}
data_obj.insert("title".into(), Value::String(reward_name.clone()));
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
let id: i64 = sqlx::query_scalar(
"INSERT INTO level_rewards (level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id",
)
.bind(payload.level_type)
.bind(level_type)
.bind(payload.server_id)
.bind(payload.level_req)
.bind(level_req)
.bind(payload.reward_type)
.bind(payload.reward_name)
.bind(reward_name)
.bind(data_str)
.bind(&now)
.fetch_one(&state.db)
@@ -221,6 +300,48 @@ pub async fn admin_create_reward(
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
}
/// Admin update reward.
pub async fn admin_update_reward(
_admin: AdminUser,
Path(id): Path<i64>,
State(state): State<AppState>,
Json(payload): Json<RewardPayload>,
) -> AppResult<Json<Value>> {
let level_req = payload.level_req.unwrap_or(2);
let reward_name = payload.reward_name.unwrap_or_else(|| "Reward".into());
let mut data_obj = match payload.reward_data {
Some(Value::Object(map)) => map,
_ => serde_json::Map::new(),
};
if let Some(desc) = payload.description {
data_obj.insert("description".into(), Value::String(desc));
}
if let Some(ico) = payload.icon {
data_obj.insert("icon".into(), Value::String(ico));
}
data_obj.insert("title".into(), Value::String(reward_name.clone()));
let data_str = serde_json::to_string(&data_obj).unwrap_or_default();
sqlx::query(
"UPDATE level_rewards SET
level_req = ?,
reward_type = ?,
reward_name = ?,
reward_data = ?
WHERE id = ?",
)
.bind(level_req)
.bind(payload.reward_type)
.bind(reward_name)
.bind(data_str)
.bind(id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "id": id, "ok": true })))
}
/// Admin delete reward.
pub async fn admin_delete_reward(
_admin: AdminUser,
+30 -4
View File
@@ -2,6 +2,7 @@ pub mod account;
pub mod achievements;
pub mod activity;
pub mod admin;
pub mod economy;
pub mod guilds;
pub mod landing;
pub mod leveling;
@@ -23,6 +24,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/launcher/manifest", get(public::manifest))
.route("/launcher/instances/{id}", get(public::instance_manifest))
.route("/launcher/events", post(public::event))
.route("/instances", get(admin::list_instances))
.route("/auth/login", post(public::login))
.route("/auth/register", post(public::register))
.route("/auth/me", get(public::me))
@@ -65,7 +67,13 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/guilds/{id}/claims", post(guilds::claim_chunk).delete(guilds::unclaim_chunk))
.route("/guilds/{id}/claim", post(guilds::claim_chunk))
.route("/guilds/{id}/unclaim", post(guilds::unclaim_chunk))
// Social: Friends, DMs, Invites, Profiles, Posts
// Economy
.route("/economy/me", get(economy::get_my_balances))
.route("/economy/balances", get(economy::get_my_balances))
.route("/economy/transactions", get(economy::get_my_transactions))
.route("/servers/{id}/economy/baltop", get(economy::get_server_baltop))
.route("/servers/{id}/economy/market", get(economy::get_server_market))
// Social: Friends, DMs, Invites, Profiles, Posts, Members Search
.route("/friends", get(social::list_friends))
.route("/friends/request", post(social::send_friend_request))
.route("/friends/respond", post(social::respond_friend_request))
@@ -87,6 +95,8 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/social/profile/{uuid}", get(social::get_player_profile))
.route("/social/posts", post(social::create_user_post))
.route("/social/posts/{id}/like", post(social::like_user_post))
.route("/social/members", get(social::search_members))
.route("/members/search", get(social::search_members))
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
let admin = Router::new()
@@ -124,13 +134,17 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/modrinth/project/{id}/versions", get(meta::modrinth_versions))
.route("/curseforge/search", get(meta::curseforge_search))
.route("/curseforge/mod/{id}/files", get(meta::curseforge_files))
// Admin Quests & Achievements & Rewards
.route("/rewards", post(leveling::admin_create_reward))
.route("/rewards/{id}", delete(leveling::admin_delete_reward))
// Admin Quests & Achievements & Rewards & Guilds
.route("/rewards", get(leveling::list_rewards).post(leveling::admin_create_reward))
.route("/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward))
.route("/levels/rewards", get(leveling::list_rewards).post(leveling::admin_create_reward))
.route("/levels/rewards/{id}", put(leveling::admin_update_reward).delete(leveling::admin_delete_reward))
.route("/quests", get(quests::admin_list_quests).post(quests::admin_create_quest))
.route("/quests/{id}", put(quests::admin_update_quest).delete(quests::admin_delete_quest))
.route("/achievements", get(achievements::admin_list_achievements).post(achievements::admin_create_achievement))
.route("/achievements/{id}", put(achievements::admin_update_achievement).delete(achievements::admin_delete_achievement))
.route("/guilds", get(guilds::admin_list_guilds))
.route("/guilds/{id}", delete(guilds::admin_delete_guild))
.layer(DefaultBodyLimit::max(upload_limit));
// Called by the SCOPENET Paper plugin and Fabric/Forge mods.
@@ -139,6 +153,18 @@ pub fn api(state: &AppState) -> Router<AppState> {
.route("/login", post(servers::login))
.route("/sync", post(servers::sync))
.route("/guilds/check-chunk", post(guilds::server_check_chunk))
.route("/guilds/claim", post(guilds::server_claim_chunk))
.route("/guilds/unclaim", post(guilds::server_unclaim_chunk))
.route("/guilds/player", post(guilds::server_get_player_guild))
.route("/guilds/create", post(guilds::server_create_guild))
.route("/guilds/leave", post(guilds::server_guild_leave))
.route("/economy/balance", post(economy::server_get_balance))
.route("/economy/pay", post(economy::server_transfer))
.route("/economy/transfer", post(economy::server_transfer))
.route("/economy/sync-balance", post(economy::server_sync_balance))
.route("/economy/baltop", post(economy::server_baltop))
.route("/economy/market/list", post(economy::server_market_list))
.route("/economy/market/buy", post(economy::server_market_buy))
.layer(DefaultBodyLimit::max(2 * 1024 * 1024));
Router::new()
+28 -12
View File
@@ -92,18 +92,23 @@ pub async fn get_my_quests(
id,
title,
description: desc,
period,
period: period.clone(),
category: cat,
target_stat: stat,
target_stat: stat.clone(),
target_count: target,
xp_reward: xp,
icon,
enabled,
quest_type: Some(period),
stat_type: Some(stat),
active: Some(enabled),
},
progress,
current_count: progress,
completed,
claimed,
period_key: pkey,
expires_at: Some(chrono::Utc::now().to_rfc3339()),
}
},
)
@@ -259,13 +264,16 @@ pub async fn admin_list_quests(
id,
title,
description: desc,
period,
period: period.clone(),
category: cat,
target_stat: stat,
target_stat: stat.clone(),
target_count: target,
xp_reward: xp,
icon,
enabled,
quest_type: Some(period),
stat_type: Some(stat),
active: Some(enabled),
},
total_completions: completions,
}
@@ -281,12 +289,15 @@ pub struct QuestPayload {
pub id: Option<String>,
pub title: String,
pub description: String,
pub period: String,
#[serde(alias = "quest_type")]
pub period: Option<String>,
pub category: String,
pub target_stat: String,
#[serde(alias = "stat_type")]
pub target_stat: Option<String>,
pub target_count: i64,
pub xp_reward: i64,
pub icon: Option<String>,
#[serde(alias = "active")]
pub enabled: Option<bool>,
}
@@ -296,10 +307,12 @@ pub async fn admin_create_quest(
State(state): State<AppState>,
Json(payload): Json<QuestPayload>,
) -> AppResult<Json<Value>> {
let period = payload.period.unwrap_or_else(|| "daily".into());
let target_stat = payload.target_stat.unwrap_or_else(|| "blocks_broken".into());
let id = payload
.id
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| format!("q_{}_{}", payload.period, uuid::Uuid::new_v4().simple()));
.unwrap_or_else(|| format!("q_{}_{}", period, uuid::Uuid::new_v4().simple()));
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
@@ -309,12 +322,12 @@ pub async fn admin_create_quest(
.bind(&id)
.bind(payload.title)
.bind(payload.description)
.bind(payload.period)
.bind(period)
.bind(payload.category)
.bind(payload.target_stat)
.bind(target_stat)
.bind(payload.target_count)
.bind(payload.xp_reward)
.bind(payload.icon.unwrap_or_else(|| "star".into()))
.bind(payload.icon.unwrap_or_else(|| "⛏️".into()))
.bind(payload.enabled.unwrap_or(true))
.bind(&now)
.execute(&state.db)
@@ -330,6 +343,9 @@ pub async fn admin_update_quest(
State(state): State<AppState>,
Json(payload): Json<QuestPayload>,
) -> AppResult<Json<Value>> {
let period = payload.period.unwrap_or_else(|| "daily".into());
let target_stat = payload.target_stat.unwrap_or_else(|| "blocks_broken".into());
sqlx::query(
"UPDATE quests SET
title = ?,
@@ -345,9 +361,9 @@ pub async fn admin_update_quest(
)
.bind(payload.title)
.bind(payload.description)
.bind(payload.period)
.bind(period)
.bind(payload.category)
.bind(payload.target_stat)
.bind(target_stat)
.bind(payload.target_count)
.bind(payload.xp_reward)
.bind(payload.icon)
+70 -1
View File
@@ -3,7 +3,7 @@
use crate::auth::AuthUser;
use crate::error::{AppError, AppResult};
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::extract::{Path, Query, State};
use axum::Json;
use scopenet_shared::{DirectMessage, FriendInfo, GameInvite, UserPost, UserProfileView};
use serde::Deserialize;
@@ -621,3 +621,72 @@ pub async fn like_user_post(
Ok(Json(serde_json::json!({ "ok": true })))
}
// ---------------------------------------------------------------------------
// Members Search & Discovery
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct SearchMembersQuery {
pub q: Option<String>,
}
pub async fn search_members(
auth: AuthUser,
Query(query): Query<SearchMembersQuery>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<scopenet_shared::MemberProfile>>> {
let q = query.q.unwrap_or_default().trim().to_lowercase();
let pattern = format!("%{q}%");
let rows: Vec<(String, String, String, String, Option<String>, Option<i64>, Option<String>, i64, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT u.uuid, u.username, u.role, u.status, u.skin_url,
ul.global_level, ul.title, u.playtime_secs, u.last_seen_ingame,
f.status as friendship_status
FROM users u
LEFT JOIN user_levels ul ON ul.uuid = u.uuid
LEFT JOIN friendships f ON (f.user_uuid = ? AND f.friend_uuid = u.uuid)
WHERE (? = '' OR LOWER(u.username) LIKE ?)
ORDER BY (CASE WHEN LOWER(u.username) = ? THEN 1 ELSE 2 END), u.playtime_secs DESC
LIMIT 60",
)
.bind(&auth.uuid)
.bind(&q)
.bind(&pattern)
.bind(&q)
.fetch_all(&state.db)
.await?;
let now_ts = chrono::Utc::now().timestamp();
let list = rows
.into_iter()
.map(|(uuid, username, role, status, skin_url, glvl, title, playtime, last_seen, friend_status)| {
let online = if let Some(ref ls) = last_seen {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ls) {
(now_ts - dt.timestamp()).abs() < 180
} else {
false
}
} else {
false
};
let is_friend = friend_status.as_deref() == Some("accepted");
scopenet_shared::MemberProfile {
uuid,
username,
role,
status,
skin_url,
global_level: glvl.unwrap_or(1),
title,
playtime_secs: playtime,
last_seen,
online,
is_friend,
}
})
.collect();
Ok(Json(list))
}