Fix Rust formatting
Server integrations / Paper and protocol tests (push) Failing after 20s
Server integrations / fabric / 1.20.1 (push) Failing after 11s
Server integrations / fabric / 1.21.1 (push) Failing after 14s
Server integrations / fabric / 26.1.2 (push) Failing after 17s
Server integrations / fabric / 26.2 (push) Failing after 12s
Server integrations / fabric / 26.3 (push) Failing after 6s
Server integrations / forge / 1.20.1 (push) Failing after 6s
Server integrations / forge / 1.21.1 (push) Failing after 8s
Server integrations / forge / 26.1.2 (push) Failing after 8s
Server integrations / forge / 26.2 (push) Failing after 8s
Server integrations / forge / 26.3 (push) Failing after 7s
CI / Web UIs (type-check + build) (panel/web) (push) Canceled after 0s
CI / Launcher (Windows build check) (push) Canceled after 2m53s
CI / Real installs (Mojang, Fabric, Quilt, Forge, NeoForge) (push) Canceled after 0s
CI / Docker image builds (push) Canceled after 0s
CI / Rust (fmt, clippy, tests) (push) Canceled after 1m22s
CI / Web UIs (type-check + build) (launcher) (push) Canceled after 1m27s

This commit is contained in:
SCOPEDD committed 2026-09-30 22:56:38 -04:00
1 parent ce49447b6e
commit 60edc99780
58 files changed
+3533 -1375

No files matched your search

+73 -17
View File
@@ -7,18 +7,67 @@ use std::path::Path;
/// Player-owned vanilla preferences. Pack-managed options.txt may be replaced
/// during sync, so these are captured separately and merged back afterward.
const VANILLA_KEYS: &[&str] = &[
"fov", "gamma", "graphicsMode", "fancyGraphics", "renderDistance", "simulationDistance",
"maxFps", "enableVsync", "fullscreen", "fullscreenResolution", "guiScale", "particles",
"clouds", "renderClouds", "entityShadows", "entityDistanceScaling", "biomeBlendRadius",
"mipmapLevels", "ao", "ambientOcclusion", "bobView", "attackIndicator", "autoJump",
"mouseSensitivity", "invertYMouse", "discrete_mouse_scroll", "mouseWheelSensitivity",
"rawMouseInput", "toggleCrouch", "toggleSprint", "sneakToggled", "sprintToggled",
"screenEffectScale", "fovEffectScale", "darknessEffectScale", "narrator", "subtitles",
"chatVisibility", "chatColors", "chatLinks", "chatLinksPrompt", "chatOpacity", "textBackgroundOpacity",
"backgroundForChatOnly", "chatScale", "chatWidth", "chatHeightFocused", "chatHeightUnfocused",
"mainHand", "lang", "forceUnicodeFont", "tutorialStep", "hideBundleTutorial",
"soundDevice", "directionalAudio", "musicFrequency", "notificationDisplayTime",
"joinedFirstServer", "pauseOnLostFocus", "reducedDebugInfo", "showSubtitles",
"fov",
"gamma",
"graphicsMode",
"fancyGraphics",
"renderDistance",
"simulationDistance",
"maxFps",
"enableVsync",
"fullscreen",
"fullscreenResolution",
"guiScale",
"particles",
"clouds",
"renderClouds",
"entityShadows",
"entityDistanceScaling",
"biomeBlendRadius",
"mipmapLevels",
"ao",
"ambientOcclusion",
"bobView",
"attackIndicator",
"autoJump",
"mouseSensitivity",
"invertYMouse",
"discrete_mouse_scroll",
"mouseWheelSensitivity",
"rawMouseInput",
"toggleCrouch",
"toggleSprint",
"sneakToggled",
"sprintToggled",
"screenEffectScale",
"fovEffectScale",
"darknessEffectScale",
"narrator",
"subtitles",
"chatVisibility",
"chatColors",
"chatLinks",
"chatLinksPrompt",
"chatOpacity",
"textBackgroundOpacity",
"backgroundForChatOnly",
"chatScale",
"chatWidth",
"chatHeightFocused",
"chatHeightUnfocused",
"mainHand",
"lang",
"forceUnicodeFont",
"tutorialStep",
"hideBundleTutorial",
"soundDevice",
"directionalAudio",
"musicFrequency",
"notificationDisplayTime",
"joinedFirstServer",
"pauseOnLostFocus",
"reducedDebugInfo",
"showSubtitles",
];
pub fn read_vanilla_preferences(game_dir: &Path) -> Result<BTreeMap<String, String>> {
@@ -28,10 +77,13 @@ pub fn read_vanilla_preferences(game_dir: &Path) -> Result<BTreeMap<String, Stri
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(e) => return Err(e.into()),
};
Ok(contents.lines().filter_map(|line| {
let (key, value) = line.split_once(':')?;
(VANILLA_KEYS.contains(&key) || key.starts_with("soundCategory_")).then(|| (key.to_owned(), value.to_owned()))
}).collect())
Ok(contents
.lines()
.filter_map(|line| {
let (key, value) = line.split_once(':')?;
(VANILLA_KEYS.contains(&key) || key.starts_with("soundCategory_")).then(|| (key.to_owned(), value.to_owned()))
})
.collect())
}
/// Merge `key_<binding>:<key>` lines into options.txt, keeping everything
@@ -85,7 +137,11 @@ mod tests {
#[test]
fn captures_only_vanilla_preferences() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("options.txt"), "fov:0.8\ngamma:1.0\nautoJump:false\nsoundCategory_music:0.25\nkey_key.forward:key.keyboard.w\nmodded:yes\n").unwrap();
std::fs::write(
dir.path().join("options.txt"),
"fov:0.8\ngamma:1.0\nautoJump:false\nsoundCategory_music:0.25\nkey_key.forward:key.keyboard.w\nmodded:yes\n",
)
.unwrap();
let prefs = read_vanilla_preferences(dir.path()).unwrap();
assert_eq!(prefs.get("fov").map(String::as_str), Some("0.8"));
assert_eq!(prefs.get("autoJump").map(String::as_str), Some("false"));
+1 -1
View File
@@ -475,7 +475,7 @@ pub struct Quest {
pub id: String,
pub title: String,
pub description: String,
pub period: String, // "daily", "weekly"
pub period: String, // "daily", "weekly"
pub category: String, // "mining", "combat", "building", "farming", "social", "exploration"
pub target_stat: String,
pub target_count: i64,
Vendored Regular → Executable
View File
File mode changed.
+55 -18
View File
@@ -155,8 +155,7 @@ pub fn set_instance_fov(state: State<'_, AppState>, instance_id: String, fov: f6
if !(-1.0..=1.0).contains(&fov) || !fov.is_finite() {
return Err("FOV must be between 30 and 110 degrees".into());
}
state.settings.write().unwrap().instance_game_options.entry(instance_id).or_default()
.insert("fov".into(), format!("{fov:.3}"));
state.settings.write().unwrap().instance_game_options.entry(instance_id).or_default().insert("fov".into(), format!("{fov:.3}"));
state.save_settings().map_err(aerr)
}
@@ -763,14 +762,18 @@ async fn guild_response_error(resp: reqwest::Response, fallback: &str) -> String
pub async fn request_guild_join(state: State<'_, AppState>, guild_id: String, message: String) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests")).await?;
let resp = req.json(&serde_json::json!({"message": message})).send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not request to join guild").await); }
if !resp.status().is_success() {
return Err(guild_response_error(resp, "could not request to join guild").await);
}
Ok(())
}
#[tauri::command]
pub async fn get_guild_join_requests(state: State<'_, AppState>, guild_id: String) -> Res<serde_json::Value> {
let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/requests")).await?.send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild requests").await); }
if !resp.status().is_success() {
return Err(guild_response_error(resp, "could not load guild requests").await);
}
resp.json().await.map_err(err)
}
@@ -778,44 +781,73 @@ pub async fn get_guild_join_requests(state: State<'_, AppState>, guild_id: Strin
pub async fn respond_guild_join_request(state: State<'_, AppState>, guild_id: String, uuid: String, accept: bool) -> Res<()> {
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests/{uuid}/respond")).await?;
let resp = req.json(&serde_json::json!({"accept": accept})).send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not review guild request").await); }
if !resp.status().is_success() {
return Err(guild_response_error(resp, "could not review guild request").await);
}
Ok(())
}
#[tauri::command]
pub async fn get_guild_roles(state: State<'_, AppState>, guild_id: String) -> Res<serde_json::Value> {
let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/roles")).await?.send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild roles").await); }
if !resp.status().is_success() {
return Err(guild_response_error(resp, "could not load guild roles").await);
}
resp.json().await.map_err(err)
}
#[tauri::command]
pub async fn create_guild_role(state: State<'_, AppState>, guild_id: String, role: serde_json::Value) -> Res<()> {
let resp = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/roles")).await?.json(&role).send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not create guild role").await); }
let resp =
account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/roles")).await?.json(&role).send().await.map_err(err)?;
if !resp.status().is_success() {
return Err(guild_response_error(resp, "could not create guild role").await);
}
Ok(())
}
#[tauri::command]
pub async fn assign_guild_role(state: State<'_, AppState>, guild_id: String, uuid: String, role: String) -> Res<()> {
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}/members/{uuid}/role")).await?.json(&serde_json::json!({"role":role})).send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not assign guild role").await); }
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}/members/{uuid}/role"))
.await?
.json(&serde_json::json!({"role":role}))
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
return Err(guild_response_error(resp, "could not assign guild role").await);
}
Ok(())
}
#[tauri::command]
pub async fn delete_guild_role(state: State<'_, AppState>, guild_id: String, role_id: i64) -> Res<()> {
let resp = account_api(&state, reqwest::Method::DELETE, &format!("/guilds/{guild_id}/roles/{role_id}")).await?.send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not delete guild role").await); }
let resp =
account_api(&state, reqwest::Method::DELETE, &format!("/guilds/{guild_id}/roles/{role_id}")).await?.send().await.map_err(err)?;
if !resp.status().is_success() {
return Err(guild_response_error(resp, "could not delete guild role").await);
}
Ok(())
}
#[tauri::command]
pub async fn update_guild(state: State<'_, AppState>, guild_id: String, description: String, motd: String, icon_url: String, banner_url: String) -> Res<()> {
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}")).await?
pub async fn update_guild(
state: State<'_, AppState>,
guild_id: String,
description: String,
motd: String,
icon_url: String,
banner_url: String,
) -> Res<()> {
let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}"))
.await?
.json(&serde_json::json!({"description":description,"motd":motd,"icon_url":icon_url,"banner_url":banner_url}))
.send().await.map_err(err)?;
if !resp.status().is_success() { return Err(guild_response_error(resp, "could not update guild").await); }
.send()
.await
.map_err(err)?;
if !resp.status().is_success() {
return Err(guild_response_error(resp, "could not update guild").await);
}
Ok(())
}
@@ -949,7 +981,13 @@ pub async fn get_guild_wallet(state: State<'_, AppState>, guild_id: String, serv
}
#[tauri::command]
pub async fn transfer_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64, amount: f64, withdraw: bool) -> Res<serde_json::Value> {
pub async fn transfer_guild_wallet(
state: State<'_, AppState>,
guild_id: String,
server_id: i64,
amount: f64,
withdraw: bool,
) -> Res<serde_json::Value> {
let action = if withdraw { "withdraw" } else { "deposit" };
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/wallet/{action}")).await?;
let resp = req.json(&serde_json::json!({"server_id":server_id,"amount":amount})).send().await.map_err(err)?;
@@ -1203,7 +1241,6 @@ pub async fn like_user_post(state: State<'_, AppState>, post_id: i64) -> Res<boo
Ok(body["liked"].as_bool().unwrap_or(false))
}
// ---------------------------------------------------------------------------
// Map actions and guild invitations
// ---------------------------------------------------------------------------
+10 -3
View File
@@ -228,7 +228,11 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
let run_id = uuid::Uuid::new_v4().to_string();
state.game.lock().unwrap().push(RunningGame { run_id: run_id.clone(), instance_id: instance_id.clone(), kill: Some(kill_tx) });
app.emit("launch://state", StatePayload { instance_id: instance_id.clone(), run_id: Some(run_id.clone()), state: "running".into(), message: None }).ok();
app.emit(
"launch://state",
StatePayload { instance_id: instance_id.clone(), run_id: Some(run_id.clone()), state: "running".into(), message: None },
)
.ok();
if let Some(window) = app.get_webview_window("main") {
match settings.after_launch.as_str() {
@@ -337,8 +341,11 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
}
}
}
app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), run_id: run_id2, code, crashed, tail: tail.into_iter().collect(), crash_report })
.ok();
app2.emit(
"game://exit",
ExitPayload { instance_id: id2.clone(), run_id: run_id2, code, crashed, tail: tail.into_iter().collect(), crash_report },
)
.ok();
emit_state(&app2, &id2, "idle", None);
});
Ok(())
+6 -18
View File
@@ -23,31 +23,19 @@ pub fn capture(state: &AppState) -> Option<Recorder> {
let accounts = state.accounts.read().unwrap();
let active = accounts.active()?;
Some(Recorder {
http: state.http.clone(),
panel,
token,
username: Some(active.username.clone()),
})
Some(Recorder { http: state.http.clone(), panel, token, username: Some(active.username.clone()) })
}
impl Recorder {
pub async fn send(&self, kind: &str, instance: &str) -> anyhow::Result<()> {
let mut req = self
.http
.post(format!("{}/api/v1/launcher/events", self.panel))
.timeout(Duration::from_secs(5));
let mut req = self.http.post(format!("{}/api/v1/launcher/events", self.panel)).timeout(Duration::from_secs(5));
if let Some(token) = &self.token {
req = req.bearer_auth(token);
}
req.json(&LaunchEvent {
kind: kind.into(),
instance_id: instance.into(),
username: self.username.clone(),
})
.send()
.await?
.error_for_status()?;
req.json(&LaunchEvent { kind: kind.into(), instance_id: instance.into(), username: self.username.clone() })
.send()
.await?
.error_for_status()?;
Ok(())
}
+273 -64
View File
@@ -49,9 +49,22 @@ pub struct EmbedStyle {
impl Default for EmbedStyle {
fn default() -> Self {
Self {
enabled: true, username: String::new(), avatar_url: String::new(), content: String::new(), title: String::new(), url: String::new(),
description: String::new(), color: "#8b6cff".into(), thumbnail: String::new(), image: String::new(), author_name: String::new(),
author_icon: String::new(), footer: String::new(), footer_icon: String::new(), timestamp: true, fields: vec![],
enabled: true,
username: String::new(),
avatar_url: String::new(),
content: String::new(),
title: String::new(),
url: String::new(),
description: String::new(),
color: "#8b6cff".into(),
thumbnail: String::new(),
image: String::new(),
author_name: String::new(),
author_icon: String::new(),
footer: String::new(),
footer_icon: String::new(),
timestamp: true,
fields: vec![],
}
}
}
@@ -146,7 +159,11 @@ pub fn fill(text: &str, vars: &Vars) -> String {
}
fn cut(s: &str, max: usize) -> String {
if s.chars().count() <= max { s.to_string() } else { format!("{}…", s.chars().take(max.saturating_sub(1)).collect::<String>()) }
if s.chars().count() <= max {
s.to_string()
} else {
format!("{}…", s.chars().take(max.saturating_sub(1)).collect::<String>())
}
}
fn color(s: &str) -> u32 {
@@ -215,7 +232,10 @@ pub fn render(style: &EmbedStyle, vars: &Vars, extra_description: Option<&str>)
e.insert("fields".into(), json!(fields));
}
let mut body = Map::new();
body.insert("username".into(), json!(cut(&if style.username.trim().is_empty() { "SCOPENET".to_string() } else { fill(&style.username, vars) }, 80)));
body.insert(
"username".into(),
json!(cut(&if style.username.trim().is_empty() { "SCOPENET".to_string() } else { fill(&style.username, vars) }, 80)),
);
if let Some(u) = https(fill(&style.avatar_url, vars)) {
body.insert("avatar_url".into(), json!(u));
}
@@ -240,7 +260,11 @@ fn base_url(state: &AppState) -> String {
pub fn avatar(state: &AppState, uuid: &str) -> String {
let base = base_url(state);
if base.is_empty() || uuid.is_empty() { String::new() } else { format!("{base}/api/v1/avatar/{uuid}?size=128") }
if base.is_empty() || uuid.is_empty() {
String::new()
} else {
format!("{base}/api/v1/avatar/{uuid}?size=128")
}
}
async fn server_name(state: &AppState) -> String {
@@ -258,9 +282,43 @@ async fn common(state: &AppState) -> Vars {
}
pub const ANNOUNCEMENT_PLACEHOLDERS: [(&str, &[&str]); 3] = [
("achievement", &["player", "uuid", "avatar", "achievement", "achievement_description", "xp", "category", "level", "rank_title", "guild", "guild_tag", "guild_line", "server_name", "panel", "time"]),
(
"achievement",
&[
"player",
"uuid",
"avatar",
"achievement",
"achievement_description",
"xp",
"category",
"level",
"rank_title",
"guild",
"guild_tag",
"guild_line",
"server_name",
"panel",
"time",
],
),
("member", &["player", "uuid", "avatar", "members_total", "server_name", "panel", "time"]),
("guild", &["guild", "tag", "leader", "leader_avatar", "guild_description", "members", "claims", "guilds_total", "server_name", "panel", "time"]),
(
"guild",
&[
"guild",
"tag",
"leader",
"leader_avatar",
"guild_description",
"members",
"claims",
"guilds_total",
"server_name",
"panel",
"time",
],
),
];
pub async fn player_vars(state: &AppState, uuid: &str, name: &str) -> Vars {
@@ -268,11 +326,22 @@ pub async fn player_vars(state: &AppState, uuid: &str, name: &str) -> Vars {
v.insert("player".into(), plain(name));
v.insert("uuid".into(), uuid.to_string());
v.insert("avatar".into(), avatar(state, uuid));
let level: Option<(i64, Option<String>)> = sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&state.db).await.ok().flatten();
let level: Option<(i64, Option<String>)> = sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?")
.bind(uuid)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let (l, t) = level.unwrap_or((1, None));
v.insert("level".into(), l.to_string());
v.insert("rank_title".into(), plain(&t.unwrap_or_default()));
let guild: Option<(String, String)> = sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?").bind(uuid).fetch_optional(&state.db).await.ok().flatten();
let guild: Option<(String, String)> =
sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?")
.bind(uuid)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let (gn, gt) = guild.unwrap_or_default();
v.insert("guild".into(), plain(&gn));
v.insert("guild_tag".into(), plain(&gt));
@@ -291,7 +360,13 @@ pub async fn guild_vars(state: &AppState, guild_id: &str) -> Vars {
.ok()
.flatten();
let (name, tag, desc, leader_uuid, members, claims) = row.unwrap_or_default();
let leader: String = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&leader_uuid).fetch_optional(&state.db).await.ok().flatten().unwrap_or_default();
let leader: String = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?")
.bind(&leader_uuid)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.unwrap_or_default();
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guilds").fetch_one(&state.db).await.unwrap_or(0);
v.insert("guild".into(), plain(&name));
v.insert("tag".into(), plain(&tag));
@@ -309,12 +384,31 @@ pub async fn sample_vars(state: &AppState, kind: &str) -> Vars {
let mut v = common(state).await;
let me = state.cfg.admin_username.clone();
let pairs: Vec<(&str, String)> = match kind {
"achievement" => vec![("player", me), ("uuid", "00000000-0000-0000-0000-000000000000".into()), ("avatar", String::new()), ("achievement", "Deep Diver".into()),
("achievement_description", "Mine 5,000 blocks below Y 0".into()), ("xp", "250".into()), ("category", "Mining".into()), ("level", "14".into()),
("rank_title", "Veteran".into()), ("guild", "Iron Wolves".into()), ("guild_tag", "IRON".into()), ("guild_line", "[IRON] Iron Wolves".into())],
"achievement" => vec![
("player", me),
("uuid", "00000000-0000-0000-0000-000000000000".into()),
("avatar", String::new()),
("achievement", "Deep Diver".into()),
("achievement_description", "Mine 5,000 blocks below Y 0".into()),
("xp", "250".into()),
("category", "Mining".into()),
("level", "14".into()),
("rank_title", "Veteran".into()),
("guild", "Iron Wolves".into()),
("guild_tag", "IRON".into()),
("guild_line", "[IRON] Iron Wolves".into()),
],
"member" => vec![("player", me), ("uuid", String::new()), ("avatar", String::new()), ("members_total", "128".into())],
_ => vec![("guild", "Iron Wolves".into()), ("tag", "IRON".into()), ("leader", me), ("leader_avatar", String::new()), ("guild_description", "Defenders of the realm.".into()),
("members", "1".into()), ("claims", "0".into()), ("guilds_total", "12".into())],
_ => vec![
("guild", "Iron Wolves".into()),
("tag", "IRON".into()),
("leader", me),
("leader_avatar", String::new()),
("guild_description", "Defenders of the realm.".into()),
("members", "1".into()),
("claims", "0".into()),
("guilds_total", "12".into()),
],
};
for (k, val) in pairs {
v.insert(k.into(), val);
@@ -327,7 +421,14 @@ pub async fn sample_vars(state: &AppState, kind: &str) -> Vars {
// ---------------------------------------------------------------------------
pub async fn post(state: &AppState, webhook: &str, body: &Value) -> AppResult<()> {
let resp = state.http.post(webhook).json(body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
let resp = state
.http
.post(webhook)
.json(body)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
if !resp.status().is_success() {
return Err(AppError::bad_request(format!("Discord refused the message (HTTP {})", resp.status().as_u16())));
}
@@ -336,7 +437,14 @@ pub async fn post(state: &AppState, webhook: &str, body: &Value) -> AppResult<()
/// Post a new message and return its id (so it can be edited later).
async fn post_returning_id(state: &AppState, webhook: &str, body: &Value) -> AppResult<String> {
let resp = state.http.post(format!("{webhook}?wait=true")).json(body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
let resp = state
.http
.post(format!("{webhook}?wait=true"))
.json(body)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
if !resp.status().is_success() {
return Err(AppError::bad_request(format!("Discord refused the message (HTTP {})", resp.status().as_u16())));
}
@@ -385,46 +493,81 @@ pub struct LiveConfig {
impl Default for LiveConfig {
fn default() -> Self {
Self { style: EmbedStyle::default(), webhook_url: String::new(), interval_secs: 120, limit: 10, sort: "playtime".into(), server_id: 0, row: String::new(), message_id: String::new() }
Self {
style: EmbedStyle::default(),
webhook_url: String::new(),
interval_secs: 120,
limit: 10,
sort: "playtime".into(),
server_id: 0,
row: String::new(),
message_id: String::new(),
}
}
}
pub fn default_live() -> BTreeMap<String, LiveConfig> {
let mut m = BTreeMap::new();
m.insert("status".to_string(), LiveConfig {
style: EmbedStyle {
enabled: false,
title: "🟢 Server status".into(),
description: "{rows}\n\n**{players_online}** players online across **{servers_online}/{servers_total}** servers".into(),
color: "#34d399".into(), footer: "{server_name} · updated".into(), ..Default::default()
m.insert(
"status".to_string(),
LiveConfig {
style: EmbedStyle {
enabled: false,
title: "🟢 Server status".into(),
description: "{rows}\n\n**{players_online}** players online across **{servers_online}/{servers_total}** servers".into(),
color: "#34d399".into(),
footer: "{server_name} · updated".into(),
..Default::default()
},
row: "{status_icon} **{server}** — {players}/{max} players · TPS {tps} · {version}".into(),
..Default::default()
},
row: "{status_icon} **{server}** — {players}/{max} players · TPS {tps} · {version}".into(),
..Default::default()
});
m.insert("guilds".to_string(), LiveConfig {
style: EmbedStyle {
enabled: false, title: "🛡️ Active guilds".into(), description: "{rows}\n\n{guilds_total} guilds · {claims_total} claimed chunks".into(),
color: "#22d3ee".into(), footer: "{server_name}".into(), ..Default::default()
);
m.insert(
"guilds".to_string(),
LiveConfig {
style: EmbedStyle {
enabled: false,
title: "🛡️ Active guilds".into(),
description: "{rows}\n\n{guilds_total} guilds · {claims_total} claimed chunks".into(),
color: "#22d3ee".into(),
footer: "{server_name}".into(),
..Default::default()
},
row: "**{rank}. [{tag}] {guild}** — level {level} · {members} members · {claims} chunks · led by {leader}".into(),
..Default::default()
},
row: "**{rank}. [{tag}] {guild}** — level {level} · {members} members · {claims} chunks · led by {leader}".into(),
..Default::default()
});
m.insert("leaderboard".to_string(), LiveConfig {
style: EmbedStyle {
enabled: false, title: "🏆 Leaderboard — {sort_label}".into(), description: "{rows}".into(),
color: "#fcd34d".into(), footer: "{server_name}".into(), ..Default::default()
);
m.insert(
"leaderboard".to_string(),
LiveConfig {
style: EmbedStyle {
enabled: false,
title: "🏆 Leaderboard — {sort_label}".into(),
description: "{rows}".into(),
color: "#fcd34d".into(),
footer: "{server_name}".into(),
..Default::default()
},
row: "`{rank}.` **{player}** — {value}".into(),
..Default::default()
},
row: "`{rank}.` **{player}** — {value}".into(),
..Default::default()
});
m.insert("baltop".to_string(), LiveConfig {
style: EmbedStyle {
enabled: false, title: "💰 Richest players".into(), description: "{rows}\n\nTotal in circulation: **{total}**".into(),
color: "#f59e0b".into(), footer: "{server_name}".into(), ..Default::default()
);
m.insert(
"baltop".to_string(),
LiveConfig {
style: EmbedStyle {
enabled: false,
title: "💰 Richest players".into(),
description: "{rows}\n\nTotal in circulation: **{total}**".into(),
color: "#f59e0b".into(),
footer: "{server_name}".into(),
..Default::default()
},
row: "`{rank}.` **{player}** — {balance}".into(),
..Default::default()
},
row: "`{rank}.` **{player}** — {balance}".into(),
..Default::default()
});
);
m
}
@@ -461,7 +604,11 @@ fn money(v: f64) -> String {
}
fn hours(secs: i64) -> String {
if secs >= 3600 { format!("{}h {}m", secs / 3600, (secs % 3600) / 60) } else { format!("{}m", secs / 60) }
if secs >= 3600 {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
} else {
format!("{}m", secs / 60)
}
}
/// The embed body for one live kind, built from current data.
@@ -486,8 +633,18 @@ pub async fn build_live(state: &AppState, kind: &str, cfg: &LiveConfig) -> AppRe
r.insert("players".into(), s.players.to_string());
r.insert("max".into(), if s.max_players > 0 { s.max_players.to_string() } else { "–".into() });
r.insert("tps".into(), s.tps.map(|t| format!("{t:.1}")).unwrap_or_else(|| "–".into()));
r.insert("version".into(), plain(&if s.version.is_empty() { s.software.clone() } else { format!("{} {}", s.software, s.version) }));
r.insert("last_seen".into(), s.last_seen.as_deref().and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|t| format!("<t:{}:R>", t.timestamp())).unwrap_or_else(|| "never".into()));
r.insert(
"version".into(),
plain(&if s.version.is_empty() { s.software.clone() } else { format!("{} {}", s.software, s.version) }),
);
r.insert(
"last_seen".into(),
s.last_seen
.as_deref()
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.map(|t| format!("<t:{}:R>", t.timestamp()))
.unwrap_or_else(|| "never".into()),
);
rows.push(fill(&row_tpl, &r));
}
}
@@ -499,13 +656,22 @@ pub async fn build_live(state: &AppState, kind: &str, cfg: &LiveConfig) -> AppRe
.bind(limit)
.fetch_all(&state.db)
.await?;
let totals: (i64, i64) = sqlx::query_as("SELECT (SELECT COUNT(*) FROM guilds), (SELECT COUNT(*) FROM guild_claims)").fetch_one(&state.db).await?;
let totals: (i64, i64) =
sqlx::query_as("SELECT (SELECT COUNT(*) FROM guilds), (SELECT COUNT(*) FROM guild_claims)").fetch_one(&state.db).await?;
vars.insert("guilds_total".into(), totals.0.to_string());
vars.insert("claims_total".into(), totals.1.to_string());
for (i, (name, tag, level, members, claims, leader, leader_uuid)) in list.into_iter().enumerate() {
let mut r = vars.clone();
for (k, v) in [("rank", (i + 1).to_string()), ("guild", plain(&name)), ("tag", plain(&tag)), ("level", level.to_string()), ("members", members.to_string()),
("claims", claims.to_string()), ("leader", plain(&leader)), ("leader_avatar", avatar(state, &leader_uuid))] {
for (k, v) in [
("rank", (i + 1).to_string()),
("guild", plain(&name)),
("tag", plain(&tag)),
("level", level.to_string()),
("members", members.to_string()),
("claims", claims.to_string()),
("leader", plain(&leader)),
("leader_avatar", avatar(state, &leader_uuid)),
] {
r.insert(k.into(), v);
}
rows.push(fill(&row_tpl, &r));
@@ -525,24 +691,53 @@ pub async fn build_live(state: &AppState, kind: &str, cfg: &LiveConfig) -> AppRe
);
let list: Vec<(String, String, i64, i64, i64, i64, i64, i64)> = sqlx::query_as(&sql).bind(limit).fetch_all(&state.db).await?;
for (i, (uuid, name, playtime, kills, blocks, level, deaths, mobs)) in list.into_iter().enumerate() {
let value = match unit { "level" => format!("level {level}"), "kills" => format!("{kills} kills"), "blocks" => format!("{blocks} blocks"), _ => hours(playtime) };
let value = match unit {
"level" => format!("level {level}"),
"kills" => format!("{kills} kills"),
"blocks" => format!("{blocks} blocks"),
_ => hours(playtime),
};
let mut r = vars.clone();
for (k, v) in [("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("value", value), ("playtime", hours(playtime)),
("kills", kills.to_string()), ("blocks", blocks.to_string()), ("level", level.to_string()), ("deaths", deaths.to_string()), ("mob_kills", mobs.to_string())] {
for (k, v) in [
("rank", (i + 1).to_string()),
("player", plain(&name)),
("avatar", avatar(state, &uuid)),
("value", value),
("playtime", hours(playtime)),
("kills", kills.to_string()),
("blocks", blocks.to_string()),
("level", level.to_string()),
("deaths", deaths.to_string()),
("mob_kills", mobs.to_string()),
] {
r.insert(k.into(), v);
}
rows.push(fill(&row_tpl, &r));
}
}
_ => {
let sid: i64 = if cfg.server_id > 0 { cfg.server_id } else { sqlx::query_scalar("SELECT COALESCE(MIN(id), 0) FROM game_servers").fetch_one(&state.db).await? };
let sid: i64 = if cfg.server_id > 0 {
cfg.server_id
} else {
sqlx::query_scalar("SELECT COALESCE(MIN(id), 0) FROM game_servers").fetch_one(&state.db).await?
};
let economy = crate::routes::servers::economy_scope(&state.db, sid).await?;
let list: Vec<(String, String, f64)> = sqlx::query_as("SELECT uuid, username, balance FROM server_economy WHERE server_id = ? ORDER BY balance DESC LIMIT ?").bind(economy).bind(limit).fetch_all(&state.db).await?;
let total: f64 = sqlx::query_scalar("SELECT COALESCE(SUM(balance), 0) FROM server_economy WHERE server_id = ?").bind(economy).fetch_one(&state.db).await?;
let list: Vec<(String, String, f64)> =
sqlx::query_as("SELECT uuid, username, balance FROM server_economy WHERE server_id = ? ORDER BY balance DESC LIMIT ?")
.bind(economy)
.bind(limit)
.fetch_all(&state.db)
.await?;
let total: f64 = sqlx::query_scalar("SELECT COALESCE(SUM(balance), 0) FROM server_economy WHERE server_id = ?")
.bind(economy)
.fetch_one(&state.db)
.await?;
vars.insert("total".into(), money(total));
for (i, (uuid, name, balance)) in list.into_iter().enumerate() {
let mut r = vars.clone();
for (k, v) in [("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("balance", money(balance))] {
for (k, v) in
[("rank", (i + 1).to_string()), ("player", plain(&name)), ("avatar", avatar(state, &uuid)), ("balance", money(balance))]
{
r.insert(k.into(), v);
}
rows.push(fill(&row_tpl, &r));
@@ -565,7 +760,14 @@ pub async fn refresh_live(state: &AppState, kind: &str, force_new: bool) -> AppR
let body = build_live(state, kind, &cfg).await?;
let mut id = if force_new { String::new() } else { cfg.message_id.clone() };
if !id.is_empty() {
let resp = state.http.patch(format!("{hook}/messages/{id}")).json(&body).timeout(Duration::from_secs(10)).send().await.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
let resp = state
.http
.patch(format!("{hook}/messages/{id}"))
.json(&body)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| AppError::bad_request(format!("Discord could not be reached: {e}")))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
id.clear(); // the message was deleted in Discord: post a fresh one
} else if !resp.status().is_success() {
@@ -616,7 +818,14 @@ mod tests {
let mut v = Vars::new();
v.insert("long".into(), "x".repeat(5000));
v.insert("img".into(), "javascript:alert(1)".into());
let style = EmbedStyle { title: "{long}".into(), description: "{long}".into(), thumbnail: "{img}".into(), color: "nonsense".into(), fields: vec![field("", "empty name", false), field("ok", "{long}", true)], ..Default::default() };
let style = EmbedStyle {
title: "{long}".into(),
description: "{long}".into(),
thumbnail: "{img}".into(),
color: "nonsense".into(),
fields: vec![field("", "empty name", false), field("ok", "{long}", true)],
..Default::default()
};
let body = render(&style, &v, None);
let e = &body["embeds"][0];
assert!(e["title"].as_str().unwrap().chars().count() <= 256);
+1 -1
View File
@@ -8,7 +8,6 @@ pub mod config;
pub mod db;
pub mod embeds;
pub mod error;
pub mod worldmap;
pub mod net;
pub mod packs;
pub mod progression;
@@ -19,6 +18,7 @@ pub mod seed;
pub mod state;
pub mod store;
pub mod textures;
pub mod worldmap;
pub mod yggdrasil;
use axum::http::{header, HeaderValue};
+13 -2
View File
@@ -72,7 +72,14 @@ pub struct Rules {
impl Default for Rules {
fn default() -> Self {
Self { starting_balance: 1000.0, guild_base_claims: 16, guild_claims_per_member: 0, guild_claims_per_level: 0, guild_max_members: 0, market_max_listings: 0 }
Self {
starting_balance: 1000.0,
guild_base_claims: 16,
guild_claims_per_member: 0,
guild_claims_per_level: 0,
guild_max_members: 0,
market_max_listings: 0,
}
}
}
@@ -244,7 +251,11 @@ pub async fn save(pool: &sqlx::SqlitePool, p: &Progression) -> AppResult<()> {
("rule_claims_per_level", p.rules.guild_claims_per_level),
("rule_guild_max_members", p.rules.guild_max_members),
] {
sqlx::query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").bind(key).bind(value.to_string()).execute(pool).await?;
sqlx::query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.bind(key)
.bind(value.to_string())
.execute(pool)
.await?;
}
Ok(())
}
+10 -2
View File
@@ -66,7 +66,10 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeRepo
report.add("ranks", run(c, "DELETE FROM player_ranks WHERE uuid = ?", &[uuid]).await?);
report.add("notifications", run(c, "DELETE FROM server_notifications WHERE uuid = ?", &[uuid]).await?);
report.add("activity", run(c, "DELETE FROM server_events WHERE uuid = ?", &[uuid]).await?);
report.add("activity", run(c, "DELETE FROM events WHERE uuid = ? OR (uuid IS NULL AND username = ? COLLATE NOCASE)", &[uuid, name]).await?);
report.add(
"activity",
run(c, "DELETE FROM events WHERE uuid = ? OR (uuid IS NULL AND username = ? COLLATE NOCASE)", &[uuid, name]).await?,
);
// ---- social ----
report.add("friends", run(c, "DELETE FROM friendships WHERE user_uuid = ? OR friend_uuid = ?", &[uuid, uuid]).await?);
@@ -119,7 +122,12 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeRepo
// ---- economy ----
report.add("economy", run(c, "DELETE FROM server_economy WHERE uuid = ?", &[uuid]).await?);
// Items a guild had listed stay for the guild; only the lister is anonymised.
run(c, "UPDATE server_market SET seller_uuid = ?, seller_name = ? WHERE seller_uuid = ? AND seller_guild_id IS NOT NULL", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
run(
c,
"UPDATE server_market SET seller_uuid = ?, seller_name = ? WHERE seller_uuid = ? AND seller_guild_id IS NOT NULL",
&[DELETED_UUID, DELETED_NAME, uuid],
)
.await?;
report.add("economy", run(c, "DELETE FROM server_market WHERE seller_uuid = ?", &[uuid]).await?);
run(c, "UPDATE economy_transactions SET from_uuid = ?, from_name = ? WHERE from_uuid = ?", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
run(c, "UPDATE economy_transactions SET to_uuid = ?, to_name = ? WHERE to_uuid = ?", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
+95 -18
View File
@@ -14,7 +14,11 @@ pub const MAX_ACTIONS: usize = 20;
const SOURCES: [&str; 3] = ["quest", "achievement", "level_reward"];
pub fn check_source(kind: &str) -> AppResult<()> {
if SOURCES.contains(&kind) { Ok(()) } else { Err(AppError::bad_request("unknown reward source")) }
if SOURCES.contains(&kind) {
Ok(())
} else {
Err(AppError::bad_request("unknown reward source"))
}
}
fn text(a: &Map<String, Value>, key: &str, max: usize) -> String {
@@ -126,7 +130,13 @@ pub fn validate(actions: &Value) -> AppResult<Vec<Value>> {
fn pretty_item(id: &str) -> String {
let name = id.rsplit(':').next().unwrap_or(id).replace(['_', '/'], " ");
name.split_whitespace().map(|w| { let mut c = w.chars(); c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default() }).collect::<Vec<_>>().join(" ")
name.split_whitespace()
.map(|w| {
let mut c = w.chars();
c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" ")
}
/// A short, player-facing line for one action (commands stay vague on purpose).
@@ -137,7 +147,15 @@ pub fn describe(a: &Value) -> String {
"item" => format!("{} x{}", pretty_item(a["item"].as_str().unwrap_or("")), n("amount")),
"permission" => {
let m = n("minutes");
let span = if m == 0 { String::new() } else if m % 1440 == 0 { format!(" for {} day{}", m / 1440, if m / 1440 == 1 { "" } else { "s" }) } else if m % 60 == 0 { format!(" for {}h", m / 60) } else { format!(" for {m}m") };
let span = if m == 0 {
String::new()
} else if m % 1440 == 0 {
format!(" for {} day{}", m / 1440, if m / 1440 == 1 { "" } else { "s" })
} else if m % 60 == 0 {
format!(" for {}h", m / 60)
} else {
format!(" for {m}m")
};
format!("Permission: {}{}", a["node"].as_str().unwrap_or(""), span)
}
"group" => format!("Group: {}", a["group"].as_str().unwrap_or("")),
@@ -149,20 +167,42 @@ pub fn describe(a: &Value) -> String {
}
pub async fn bundle(conn: &mut SqliteConnection, kind: &str, id: &str) -> AppResult<Vec<Value>> {
let raw: Option<String> = sqlx::query_scalar("SELECT actions FROM reward_bundles WHERE source_type = ? AND source_id = ?").bind(kind).bind(id).fetch_optional(&mut *conn).await?;
let raw: Option<String> = sqlx::query_scalar("SELECT actions FROM reward_bundles WHERE source_type = ? AND source_id = ?")
.bind(kind)
.bind(id)
.fetch_optional(&mut *conn)
.await?;
Ok(raw.and_then(|r| serde_json::from_str::<Vec<Value>>(&r).ok()).unwrap_or_default())
}
/// Queue a bundle for a player who just earned its source. Does nothing when the source has no bundle.
pub async fn enqueue(conn: &mut SqliteConnection, uuid: &str, kind: &str, id: &str) -> AppResult<()> {
let has: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM reward_bundles WHERE source_type = ? AND source_id = ? AND actions <> '[]')").bind(kind).bind(id).fetch_one(&mut *conn).await?;
let has: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM reward_bundles WHERE source_type = ? AND source_id = ? AND actions <> '[]')")
.bind(kind)
.bind(id)
.fetch_one(&mut *conn)
.await?;
if has {
sqlx::query("INSERT INTO reward_queue (uuid, source_type, source_id, created_at) VALUES (?, ?, ?, ?)").bind(uuid).bind(kind).bind(id).bind(crate::db::now()).execute(&mut *conn).await?;
sqlx::query("INSERT INTO reward_queue (uuid, source_type, source_id, created_at) VALUES (?, ?, ?, ?)")
.bind(uuid)
.bind(kind)
.bind(id)
.bind(crate::db::now())
.execute(&mut *conn)
.await?;
}
Ok(())
}
async fn credit(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, name: &str, amount: f64, server: Option<i64>, note: &str) -> AppResult<()> {
async fn credit(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
uuid: &str,
name: &str,
amount: f64,
server: Option<i64>,
note: &str,
) -> AppResult<()> {
// One credit per economy: servers sharing a group share balances, so they must not be paid twice.
let servers: Vec<i64> = match server {
Some(id) => vec![id],
@@ -184,7 +224,13 @@ async fn credit(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, name:
let now = chrono::Utc::now().to_rfc3339();
for economy in economies {
crate::routes::economy::ensure_balance(tx, economy, uuid, name).await?;
sqlx::query("UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ?").bind(amount).bind(&now).bind(economy).bind(uuid).execute(&mut **tx).await?;
sqlx::query("UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
.bind(amount)
.bind(&now)
.bind(economy)
.bind(uuid)
.execute(&mut **tx)
.await?;
sqlx::query("INSERT INTO economy_transactions(server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at) VALUES (?, 'server', 'Rewards', ?, ?, ?, ?, ?)")
.bind(economy).bind(uuid).bind(name).bind(amount).bind(note).bind(&now).execute(&mut **tx).await?;
}
@@ -202,15 +248,18 @@ async fn source_label(conn: &mut SqliteConnection, kind: &str, id: &str) -> Stri
/// Apply every queued bundle. Safe to call often; each queue row is handled exactly once.
pub async fn process_queue(state: &AppState) -> AppResult<usize> {
let rows: Vec<(i64, String, String, String)> = sqlx::query_as("SELECT id, uuid, source_type, source_id FROM reward_queue ORDER BY id LIMIT 200").fetch_all(&state.db).await?;
let rows: Vec<(i64, String, String, String)> =
sqlx::query_as("SELECT id, uuid, source_type, source_id FROM reward_queue ORDER BY id LIMIT 200").fetch_all(&state.db).await?;
let mut done = 0;
for (queue_id, uuid, kind, id) in rows {
let mut tx = state.db.begin().await?;
let gone: Option<i64> = sqlx::query_scalar("DELETE FROM reward_queue WHERE id = ? RETURNING id").bind(queue_id).fetch_optional(&mut *tx).await?;
let gone: Option<i64> =
sqlx::query_scalar("DELETE FROM reward_queue WHERE id = ? RETURNING id").bind(queue_id).fetch_optional(&mut *tx).await?;
if gone.is_none() {
continue; // another request got there first
}
let name: String = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or_default();
let name: String =
sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or_default();
if name.is_empty() {
tx.commit().await?;
continue;
@@ -221,13 +270,19 @@ pub async fn process_queue(state: &AppState) -> AppResult<usize> {
for action in bundle(&mut tx, &kind, &id).await? {
let server = action["server_id"].as_i64();
match action["type"].as_str().unwrap_or("") {
"money" => credit(&mut tx, &uuid, &name, action["amount"].as_f64().unwrap_or(0.0), server, &format!("Reward: {label}")).await?,
"money" => {
credit(&mut tx, &uuid, &name, action["amount"].as_f64().unwrap_or(0.0), server, &format!("Reward: {label}")).await?
}
"claim_chunks" => {
sqlx::query("INSERT INTO player_bonuses (uuid, claim_chunks) VALUES (?, ?) ON CONFLICT(uuid) DO UPDATE SET claim_chunks = claim_chunks + excluded.claim_chunks")
.bind(&uuid).bind(action["amount"].as_i64().unwrap_or(0)).execute(&mut *tx).await?;
}
"badge" => {
let raw: String = sqlx::query_scalar("SELECT badges FROM user_levels WHERE uuid = ?").bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or_else(|| "[]".into());
let raw: String = sqlx::query_scalar("SELECT badges FROM user_levels WHERE uuid = ?")
.bind(&uuid)
.fetch_optional(&mut *tx)
.await?
.unwrap_or_else(|| "[]".into());
let mut badges: Vec<String> = serde_json::from_str(&raw).unwrap_or_default();
let badge = action["badge"].as_str().unwrap_or("").to_string();
if !badge.is_empty() && !badges.contains(&badge) {
@@ -239,8 +294,17 @@ pub async fn process_queue(state: &AppState) -> AppResult<usize> {
other => {
let mut payload = action.clone();
payload["reason"] = json!(label);
sqlx::query("INSERT INTO reward_deliveries (uuid, server_id, kind, payload, source, created_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(&uuid).bind(server).bind(other).bind(payload.to_string()).bind(&source).bind(&now).execute(&mut *tx).await?;
sqlx::query(
"INSERT INTO reward_deliveries (uuid, server_id, kind, payload, source, created_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&uuid)
.bind(server)
.bind(other)
.bind(payload.to_string())
.bind(&source)
.bind(&now)
.execute(&mut *tx)
.await?;
}
}
}
@@ -270,7 +334,11 @@ pub async fn poll(state: &AppState, server_id: i64) -> AppResult<Vec<Value>> {
.await?;
let now = crate::db::now();
for (id, ..) in &rows {
sqlx::query("UPDATE reward_deliveries SET sent_at = ?, attempts = attempts + 1 WHERE id = ?").bind(&now).bind(id).execute(&mut *tx).await?;
sqlx::query("UPDATE reward_deliveries SET sent_at = ?, attempts = attempts + 1 WHERE id = ?")
.bind(&now)
.bind(id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(rows.into_iter().map(|(id, uuid, name, kind, payload)| json!({ "id": id, "uuid": uuid, "name": name, "kind": kind, "payload": serde_json::from_str::<Value>(&payload).unwrap_or(json!({})) })).collect())
@@ -281,12 +349,21 @@ pub async fn ack(state: &AppState, done: &[i64], failed: &[(i64, String)]) -> Ap
let now = crate::db::now();
let mut tx = state.db.begin().await?;
for id in done {
sqlx::query("UPDATE reward_deliveries SET delivered_at = ?, error = NULL WHERE id = ?").bind(&now).bind(id).execute(&mut *tx).await?;
sqlx::query("UPDATE reward_deliveries SET delivered_at = ?, error = NULL WHERE id = ?")
.bind(&now)
.bind(id)
.execute(&mut *tx)
.await?;
}
for (id, error) in failed {
let error: String = error.chars().filter(|c| !c.is_control()).take(200).collect();
// A refused delivery (e.g. commands switched off on that server) is retried a few times, then left for the admin to see.
sqlx::query("UPDATE reward_deliveries SET error = ?, sent_at = ? WHERE id = ? AND delivered_at IS NULL").bind(error).bind(&now).bind(id).execute(&mut *tx).await?;
sqlx::query("UPDATE reward_deliveries SET error = ?, sent_at = ? WHERE id = ? AND delivered_at IS NULL")
.bind(error)
.bind(&now)
.bind(id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
+55 -136
View File
@@ -10,39 +10,17 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Get all achievements for authenticated user with their unlock status.
pub async fn get_my_achievements(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<Achievement>>> {
pub async fn get_my_achievements(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<Vec<Achievement>>> {
get_achievements_for_user(&state, &auth.uuid, false).await.map(Json)
}
/// Get public unlocked achievements for a specific player profile.
pub async fn get_player_achievements(
Path(uuid): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<Achievement>>> {
pub async fn get_player_achievements(Path(uuid): Path<String>, State(state): State<AppState>) -> AppResult<Json<Vec<Achievement>>> {
get_achievements_for_user(&state, &uuid, true).await.map(Json)
}
pub async fn get_achievements_for_user(
state: &AppState,
uuid: &str,
unlocked_only: bool,
) -> AppResult<Vec<Achievement>> {
let rows: Vec<(
String,
String,
String,
String,
String,
String,
String,
String,
i64,
bool,
Option<String>,
)> = sqlx::query_as(
pub async fn get_achievements_for_user(state: &AppState, uuid: &str, unlocked_only: bool) -> AppResult<Vec<Achievement>> {
let rows: Vec<(String, String, String, String, String, String, String, String, i64, bool, Option<String>)> = sqlx::query_as(
"SELECT a.id, a.title, a.description, a.category, a.icon_frame, a.icon_item,
a.icon_bg, a.icon_border, a.xp_reward, a.secret, ua.unlocked_at
FROM achievements a
@@ -55,43 +33,29 @@ pub async fn get_achievements_for_user(
let list = rows
.into_iter()
.filter_map(
|(
.filter_map(|(id, title, desc, cat, frame, item, bg, border, xp, secret, unlocked_at)| {
let unlocked = unlocked_at.is_some();
if unlocked_only && !unlocked {
return None;
}
Some(Achievement {
id,
title,
desc,
cat,
frame,
item,
bg,
border,
xp,
description: desc,
category: cat,
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,
)| {
let unlocked = unlocked_at.is_some();
if unlocked_only && !unlocked {
return None;
}
Some(Achievement {
id,
title,
description: desc,
category: cat,
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,
})
},
)
})
})
.collect();
Ok(list)
@@ -114,26 +78,8 @@ pub struct AdminAchievementRow {
}
/// Admin list all achievements.
pub async fn admin_list_achievements(
_admin: AdminUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<AdminAchievementRow>>> {
let rows: Vec<(
String,
String,
String,
String,
String,
String,
String,
String,
String,
String,
i64,
i64,
bool,
i64,
)> = sqlx::query_as(
pub async fn admin_list_achievements(_admin: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<AdminAchievementRow>>> {
let rows: Vec<(String, String, String, String, String, String, String, String, String, String, i64, i64, bool, i64)> = sqlx::query_as(
"SELECT a.id, a.title, a.description, a.category, a.icon_frame, a.icon_item,
a.icon_bg, a.icon_border, a.requirement_type, a.requirement_key,
a.requirement_value, a.xp_reward, a.secret,
@@ -148,50 +94,33 @@ pub async fn admin_list_achievements(
let list = rows
.into_iter()
.map(
|(
id,
title,
desc,
cat,
frame,
item,
bg,
border,
req_type,
req_key,
req_val,
xp,
secret,
.map(|(id, title, desc, cat, frame, item, bg, border, req_type, req_key, req_val, xp, secret, total_unlocked)| {
AdminAchievementRow {
achievement: Achievement {
id,
title,
description: desc,
category: cat,
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.clone(),
requirement_value: req_val,
stat_type: req_key,
target_count: req_val,
total_unlocked,
)| {
AdminAchievementRow {
achievement: Achievement {
id,
title,
description: desc,
category: cat,
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.clone(),
requirement_value: req_val,
stat_type: req_key,
target_count: req_val,
total_unlocked,
}
},
)
}
})
.collect();
Ok(Json(list))
@@ -224,10 +153,7 @@ pub async fn admin_create_achievement(
State(state): State<AppState>,
Json(payload): Json<AchievementPayload>,
) -> AppResult<Json<Value>> {
let id = payload
.id
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| format!("ach_{}", uuid::Uuid::new_v4().simple()));
let id = payload.id.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());
@@ -267,7 +193,7 @@ pub async fn admin_update_achievement(
// FIX #19: Only overwrite requirement_type / key / value when the caller
// explicitly supplies them. Use COALESCE so NULL leaves the existing value.
let req_type = payload.requirement_type; // None if not in the request
let req_key = payload.requirement_key; // None if not in the request
let req_key = payload.requirement_key; // None if not in the request
let req_val = payload.requirement_value; // None if not in the request
sqlx::query(
@@ -306,15 +232,8 @@ pub async fn admin_update_achievement(
}
/// Admin delete achievement.
pub async fn admin_delete_achievement(
_admin: AdminUser,
Path(id): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
pub async fn admin_delete_achievement(_admin: AdminUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM reward_bundles WHERE source_type = 'achievement' AND source_id = ?").bind(&id).execute(&state.db).await?;
sqlx::query("DELETE FROM achievements WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await?;
sqlx::query("DELETE FROM achievements WHERE id = ?").bind(&id).execute(&state.db).await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+8 -1
View File
@@ -234,7 +234,14 @@ pub async fn delete_user(AdminUser(me): AdminUser, State(state): State<AppState>
.ok_or_else(|| AppError::not_found("player not found"))?;
// Removes the account and everything tied to its UUID, not just the login.
let report = crate::purge::purge_user(&state, &user).await?;
crate::routes::activity::record(&state, &me, "panel", "account_deleted", Some(&format!("{} ({} records removed)", user.username, report.total()))).await?;
crate::routes::activity::record(
&state,
&me,
"panel",
"account_deleted",
Some(&format!("{} ({} records removed)", user.username, report.total())),
)
.await?;
Ok(Json(json!({ "ok": true, "report": report })))
}
+103 -22
View File
@@ -22,7 +22,8 @@ fn dashed_or_err(uuid: &str) -> AppResult<String> {
/// Everything a placeholder or `getPlayer()` needs in one round trip.
pub async fn player_info(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Who>) -> AppResult<Json<Value>> {
let uuid = dashed_or_err(&p.uuid)?;
let user: Option<(String, String, String)> = sqlx::query_as("SELECT username, role, created_at FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&state.db).await?;
let user: Option<(String, String, String)> =
sqlx::query_as("SELECT username, role, created_at FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&state.db).await?;
let Some((name, role, joined)) = user else {
return Ok(Json(json!({ "exists": false, "uuid": uuid })));
};
@@ -38,10 +39,22 @@ pub async fn player_info(GameServer(server): GameServer, State(state): State<App
.await?;
let balance = crate::routes::economy::balance_for(&state, server.id, &uuid).await?;
let (playtime, kills, deaths): (Option<i64>, Option<i64>, Option<i64>) =
sqlx::query_as("SELECT SUM(playtime_secs), SUM(player_kills + mob_kills), SUM(deaths) FROM player_stats WHERE uuid = ?").bind(&uuid).fetch_one(&state.db).await?;
let (server_playtime,): (Option<i64>,) = sqlx::query_as("SELECT SUM(playtime_secs) FROM player_stats WHERE uuid = ? AND server_id = ?").bind(&uuid).bind(server.id).fetch_one(&state.db).await?;
let friends: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM friendships WHERE status = 'accepted' AND (user_uuid = ?1 OR friend_uuid = ?1)").bind(&uuid).fetch_one(&state.db).await?;
let achievements: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?").bind(&uuid).fetch_one(&state.db).await?;
sqlx::query_as("SELECT SUM(playtime_secs), SUM(player_kills + mob_kills), SUM(deaths) FROM player_stats WHERE uuid = ?")
.bind(&uuid)
.fetch_one(&state.db)
.await?;
let (server_playtime,): (Option<i64>,) = sqlx::query_as("SELECT SUM(playtime_secs) FROM player_stats WHERE uuid = ? AND server_id = ?")
.bind(&uuid)
.bind(server.id)
.fetch_one(&state.db)
.await?;
let friends: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM friendships WHERE status = 'accepted' AND (user_uuid = ?1 OR friend_uuid = ?1)")
.bind(&uuid)
.fetch_one(&state.db)
.await?;
let achievements: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?").bind(&uuid).fetch_one(&state.db).await?;
// Quest progress for this period: how many of the player's quests are done.
let cfg = crate::progression::load_pool(&state.db).await?;
@@ -65,8 +78,14 @@ pub async fn player_info(GameServer(server): GameServer, State(state): State<App
quests.insert(period.into(), json!({ "total": ids.len(), "completed": done, "claimed": claimed }));
}
drop(conn); // give the connection back before the pool is used again
let rank: Option<(String, String, String, String)> = sqlx::query_as("SELECT primary_group, display, prefix, suffix FROM player_ranks WHERE server_id = ? AND uuid = ?").bind(server.id).bind(&uuid).fetch_optional(&state.db).await?;
let online: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?;
let rank: Option<(String, String, String, String)> =
sqlx::query_as("SELECT primary_group, display, prefix, suffix FROM player_ranks WHERE server_id = ? AND uuid = ?")
.bind(server.id)
.bind(&uuid)
.fetch_optional(&state.db)
.await?;
let online: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(&uuid).fetch_one(&state.db).await?;
Ok(Json(json!({
"exists": true,
@@ -122,7 +141,8 @@ pub async fn add_xp(GameServer(server): GameServer, State(state): State<AppState
return Err(AppError::not_found("player not found"));
}
let now = chrono::Utc::now().to_rfc3339();
let (mode, amount) = if p.amount > 0 { (crate::progression::Mode::Add, p.amount) } else { (crate::progression::Mode::Remove, -p.amount) };
let (mode, amount) =
if p.amount > 0 { (crate::progression::Mode::Add, p.amount) } else { (crate::progression::Mode::Remove, -p.amount) };
let change = match p.scope.as_str() {
"global" => crate::progression::adjust_global(&mut tx, &uuid, mode, amount, &now).await?,
"server" => crate::progression::adjust_server(&mut tx, server.id, &uuid, mode, amount, &now).await?,
@@ -131,9 +151,30 @@ pub async fn add_xp(GameServer(server): GameServer, State(state): State<AppState
if change.new_xp > change.old_xp {
crate::routes::leveling::grant_rewards(&mut tx, &uuid, &now).await?;
}
let detail = format!("{} XP {} → {} (level {} → {}) via {}: {}", p.scope, change.old_xp, change.new_xp, change.old_level, change.new_level, server.name, p.reason.chars().filter(|c| !c.is_control()).take(100).collect::<String>());
sqlx::query("INSERT INTO server_events (server_id, uuid, name, kind, detail, created_at) VALUES (?, ?, NULL, 'xp', ?, ?)").bind(server.id).bind(&uuid).bind(detail).bind(crate::db::now()).execute(&mut *tx).await?;
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "scope": p.scope, "xp": change.new_xp, "level": change.new_level, "previous_level": change.old_level })).await
let detail = format!(
"{} XP {} → {} (level {} → {}) via {}: {}",
p.scope,
change.old_xp,
change.new_xp,
change.old_level,
change.new_level,
server.name,
p.reason.chars().filter(|c| !c.is_control()).take(100).collect::<String>()
);
sqlx::query("INSERT INTO server_events (server_id, uuid, name, kind, detail, created_at) VALUES (?, ?, NULL, 'xp', ?, ?)")
.bind(server.id)
.bind(&uuid)
.bind(detail)
.bind(crate::db::now())
.execute(&mut *tx)
.await?;
finish_operation(
tx,
server.id,
&p.operation_id,
json!({ "ok": true, "scope": p.scope, "xp": change.new_xp, "level": change.new_level, "previous_level": change.old_level }),
)
.await
}
#[derive(Deserialize)]
@@ -148,7 +189,11 @@ pub struct ObjectivePayload {
}
/// `completeQuestObjective()`: move a player's quest forward from another plugin.
pub async fn quest_objective(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<ObjectivePayload>) -> AppResult<Json<Value>> {
pub async fn quest_objective(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(p): Json<ObjectivePayload>,
) -> AppResult<Json<Value>> {
let uuid = dashed_or_err(&p.uuid)?;
if !(0..=1_000_000).contains(&p.amount) || p.objective.is_empty() || p.objective.len() > 120 {
return Err(AppError::bad_request("invalid objective"));
@@ -158,7 +203,10 @@ pub async fn quest_objective(GameServer(server): GameServer, State(state): State
return Ok(Json(previous));
}
let now = crate::db::now();
let quest: Option<(String, i64)> = sqlx::query_as("SELECT period, target_count FROM quests WHERE id = ? AND enabled = 1").bind(&p.objective).fetch_optional(&mut *tx).await?;
let quest: Option<(String, i64)> = sqlx::query_as("SELECT period, target_count FROM quests WHERE id = ? AND enabled = 1")
.bind(&p.objective)
.fetch_optional(&mut *tx)
.await?;
let advanced = if let Some((period, target)) = quest {
let cfg = crate::progression::load(&mut tx).await?;
let key = if period == "weekly" { crate::routes::quests::current_weekly_key() } else { crate::routes::quests::current_daily_key() };
@@ -177,9 +225,15 @@ pub async fn quest_objective(GameServer(server): GameServer, State(state): State
true
} else {
// Not a quest id: treat it as an action ("block_broken:STONE") like the plugin's own events.
let before: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?").bind(&uuid).fetch_one(&mut *tx).await?;
let before: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?")
.bind(&uuid)
.fetch_one(&mut *tx)
.await?;
crate::routes::quests::advance_action_quests(&mut tx, &uuid, &p.objective, p.amount.max(1), &now).await?;
let after: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?").bind(&uuid).fetch_one(&mut *tx).await?;
let after: i64 = sqlx::query_scalar("SELECT COALESCE(SUM(progress), 0) FROM user_quests WHERE user_uuid = ?")
.bind(&uuid)
.fetch_one(&mut *tx)
.await?;
after > before
};
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "advanced": advanced })).await
@@ -197,7 +251,9 @@ pub async fn friends(GameServer(_): GameServer, State(state): State<AppState>, J
.bind(&uuid)
.fetch_all(&state.db)
.await?;
Ok(Json(json!({ "friends": rows.into_iter().map(|(uuid, name, online, playing_on)| json!({ "uuid": uuid, "name": name, "online": online, "playing_on": playing_on })).collect::<Vec<_>>() })))
Ok(Json(
json!({ "friends": rows.into_iter().map(|(uuid, name, online, playing_on)| json!({ "uuid": uuid, "name": name, "online": online, "playing_on": playing_on })).collect::<Vec<_>>() }),
))
}
#[derive(Deserialize)]
@@ -213,16 +269,41 @@ pub struct GuildQuery {
pub async fn guild(GameServer(server): GameServer, State(state): State<AppState>, Json(q): Json<GuildQuery>) -> AppResult<Json<Value>> {
let id: Option<String> = if !q.member.is_empty() {
let uuid = dashed_or_err(&q.member)?;
sqlx::query_scalar("SELECT g.id FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?").bind(uuid).bind(&server.instance_id).fetch_optional(&state.db).await?
sqlx::query_scalar("SELECT g.id FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ?")
.bind(uuid)
.bind(&server.instance_id)
.fetch_optional(&state.db)
.await?
} else {
sqlx::query_scalar("SELECT id FROM guilds WHERE instance_id = ?2 AND (id = ?1 OR tag = ?1 COLLATE NOCASE OR name = ?1 COLLATE NOCASE)").bind(q.guild.trim()).bind(&server.instance_id).fetch_optional(&state.db).await?
sqlx::query_scalar(
"SELECT id FROM guilds WHERE instance_id = ?2 AND (id = ?1 OR tag = ?1 COLLATE NOCASE OR name = ?1 COLLATE NOCASE)",
)
.bind(q.guild.trim())
.bind(&server.instance_id)
.fetch_optional(&state.db)
.await?
};
let Some(id) = id else { return Ok(Json(json!({ "exists": false }))) };
let (name, tag, desc, leader, created): (String, String, String, String, String) =
sqlx::query_as("SELECT name, tag, description, leader_uuid, created_at FROM guilds WHERE id = ?").bind(&id).fetch_one(&state.db).await?;
let members: Vec<(String, String, String)> = sqlx::query_as("SELECT uuid, name, role FROM guild_members WHERE guild_id = ? ORDER BY joined_at").bind(&id).fetch_all(&state.db).await?;
let claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ? AND server_id = ?").bind(&id).bind(server.id).fetch_one(&state.db).await?;
let balance: f64 = sqlx::query_scalar("SELECT COALESCE((SELECT balance FROM guild_wallets WHERE guild_id = ? AND server_id = ?), 0.0)").bind(&id).bind(server.id).fetch_one(&state.db).await?;
sqlx::query_as("SELECT name, tag, description, leader_uuid, created_at FROM guilds WHERE id = ?")
.bind(&id)
.fetch_one(&state.db)
.await?;
let members: Vec<(String, String, String)> =
sqlx::query_as("SELECT uuid, name, role FROM guild_members WHERE guild_id = ? ORDER BY joined_at")
.bind(&id)
.fetch_all(&state.db)
.await?;
let claims: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_claims WHERE guild_id = ? AND server_id = ?")
.bind(&id)
.bind(server.id)
.fetch_one(&state.db)
.await?;
let balance: f64 = sqlx::query_scalar("SELECT COALESCE((SELECT balance FROM guild_wallets WHERE guild_id = ? AND server_id = ?), 0.0)")
.bind(&id)
.bind(server.id)
.fetch_one(&state.db)
.await?;
Ok(Json(json!({
"exists": true, "id": id, "name": name, "tag": tag, "description": desc, "leader": leader, "created": created,
"claims": claims, "balance": balance,
+120 -32
View File
@@ -6,12 +6,12 @@
//! managed level-title roles are replaced when a player's rank changes.
use crate::auth::{AdminUser, AuthUser};
use crate::embeds;
use crate::error::{AppError, AppResult};
use crate::routes::connections::ConnectionsSettings;
use crate::state::AppState;
use crate::store;
use axum::extract::{Path, State};
use crate::embeds;
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -37,7 +37,15 @@ pub struct DiscordSettings {
impl Default for DiscordSettings {
fn default() -> Self {
Self { role_sync: "off".into(), base_role_id: String::new(), invite_url: String::new(), webhook_url: String::new(), notify_achievements: true, notify_guilds: true, notify_members: false }
Self {
role_sync: "off".into(),
base_role_id: String::new(),
invite_url: String::new(),
webhook_url: String::new(),
notify_achievements: true,
notify_guilds: true,
notify_members: false,
}
}
}
@@ -47,13 +55,19 @@ pub async fn load(state: &AppState) -> AppResult<DiscordSettings> {
pub fn valid_webhook(url: &str) -> bool {
url.is_empty()
|| ["https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/", "https://ptb.discord.com/api/webhooks/", "https://canary.discord.com/api/webhooks/"]
.iter()
.any(|p| url.starts_with(p))
|| [
"https://discord.com/api/webhooks/",
"https://discordapp.com/api/webhooks/",
"https://ptb.discord.com/api/webhooks/",
"https://canary.discord.com/api/webhooks/",
]
.iter()
.any(|p| url.starts_with(p))
}
pub fn valid_invite(url: &str) -> bool {
url.is_empty() || ["https://discord.gg/", "https://discord.com/invite/", "https://discordapp.com/invite/"].iter().any(|p| url.starts_with(p))
url.is_empty()
|| ["https://discord.gg/", "https://discord.com/invite/", "https://discordapp.com/invite/"].iter().any(|p| url.starts_with(p))
}
fn view(s: &DiscordSettings) -> Value {
@@ -66,7 +80,8 @@ fn view(s: &DiscordSettings) -> Value {
pub async fn get_settings(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
let conn: ConnectionsSettings = store::kv_get(&state, "connections_settings").await?;
let linked: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_connections WHERE provider = 'discord'").fetch_one(&state.db).await?;
let linked: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM account_connections WHERE provider = 'discord'").fetch_one(&state.db).await?;
let mut v = view(&load(&state).await?);
v["bot_ready"] = json!(!conn.discord_bot_token.is_empty() && !conn.discord_guild_id.is_empty());
v["linked_accounts"] = json!(linked);
@@ -124,7 +139,10 @@ pub async fn put_settings(_: AdminUser, State(state): State<AppState>, Json(i):
pub async fn public_info(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<Value>> {
let s = load(&state).await?;
let linked: Option<(String, String)> =
sqlx::query_as("SELECT provider_id, display_name FROM account_connections WHERE user_id = ? AND provider = 'discord'").bind(user.id).fetch_optional(&state.db).await?;
sqlx::query_as("SELECT provider_id, display_name FROM account_connections WHERE user_id = ? AND provider = 'discord'")
.bind(user.id)
.fetch_optional(&state.db)
.await?;
Ok(Json(json!({ "invite_url": s.invite_url, "linked": linked.is_some(), "display_name": linked.map(|l| l.1) })))
}
@@ -137,7 +155,12 @@ pub struct RoleMapping {
pub discord_role: String,
}
pub async fn set_role_mapping(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>, Json(m): Json<RoleMapping>) -> AppResult<Json<Value>> {
pub async fn set_role_mapping(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(m): Json<RoleMapping>,
) -> AppResult<Json<Value>> {
let role = m.discord_role.trim();
if !role.bytes().all(|b| b.is_ascii_digit()) || role.len() > 24 {
return Err(AppError::bad_request("Discord role IDs are numbers"));
@@ -222,16 +245,28 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
if c.discord_bot_token.is_empty() || c.discord_guild_id.is_empty() {
return Err(AppError::bad_request("save a Discord bot token and server ID in Settings first"));
}
let mapping: Vec<(i64, String)> = sqlx::query_as("SELECT id, discord_role FROM groups WHERE discord_role <> ''").fetch_all(&state.db).await?;
let mapping: Vec<(i64, String)> =
sqlx::query_as("SELECT id, discord_role FROM groups WHERE discord_role <> ''").fetch_all(&state.db).await?;
let rank_roles: Vec<(i64, String)> = sqlx::query_as("SELECT level_req, json_extract(reward_data, '$.discord_role_id') FROM level_rewards WHERE level_type='global' AND reward_type='title' AND json_extract(reward_data, '$.discord_role_id') IS NOT NULL AND json_extract(reward_data, '$.discord_role_id') <> '' ORDER BY level_req DESC")
.fetch_all(&state.db).await?;
if mapping.is_empty() && rank_roles.is_empty() && settings.base_role_id.is_empty() {
return Ok(report);
}
let linked: Vec<(i64, String, String)> = sqlx::query_as("SELECT c.user_id, c.provider_id, u.uuid FROM account_connections c JOIN users u ON u.id=c.user_id WHERE c.provider = 'discord'").fetch_all(&state.db).await?;
let linked: Vec<(i64, String, String)> = sqlx::query_as(
"SELECT c.user_id, c.provider_id, u.uuid FROM account_connections c JOIN users u ON u.id=c.user_id WHERE c.provider = 'discord'",
)
.fetch_all(&state.db)
.await?;
for (user_id, discord_id, uuid) in linked {
report.checked += 1;
let resp = bot_request(state, reqwest::Method::GET, format!("{API}/guilds/{}/members/{discord_id}", c.discord_guild_id), &c.discord_bot_token).send().await;
let resp = bot_request(
state,
reqwest::Method::GET,
format!("{API}/guilds/{}/members/{discord_id}", c.discord_guild_id),
&c.discord_bot_token,
)
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(_) => {
@@ -248,29 +283,55 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
report.failed += 1;
break;
}
if !resp.status().is_success() { report.failed += 1; continue; }
if !resp.status().is_success() {
report.failed += 1;
continue;
}
let Ok(member) = resp.json::<Value>().await else {
report.failed += 1;
continue;
};
let roles: Vec<String> = member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default();
let groups: Vec<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&state.db).await?;
let roles: Vec<String> =
member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default();
let groups: Vec<i64> =
sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&state.db).await?;
let p = plan(&settings.role_sync, &mapping, &roles, &groups);
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&state.db).await?.unwrap_or(0);
let xp: i64 =
sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&state.db).await?.unwrap_or(0);
let level = crate::progression::level_from_xp(xp).0;
let chosen = rank_roles.iter().find(|(required,_)| *required <= level).map(|(_,role)| role.clone())
let chosen = rank_roles
.iter()
.find(|(required, _)| *required <= level)
.map(|(_, role)| role.clone())
.or_else(|| (!settings.base_role_id.is_empty()).then(|| settings.base_role_id.clone()));
for g in p.add_groups {
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)").bind(user_id).bind(g).execute(&state.db).await?;
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)")
.bind(user_id)
.bind(g)
.execute(&state.db)
.await?;
report.groups_added += 1;
}
// A level title takes precedence over mapped panel groups on Discord.
let wanted_roles = if let Some(role) = &chosen { if roles.contains(role) { Vec::new() } else { vec![role.clone()] } } else { p.add_roles };
let wanted_roles = if let Some(role) = &chosen {
if roles.contains(role) {
Vec::new()
} else {
vec![role.clone()]
}
} else {
p.add_roles
};
for role in wanted_roles {
let done = bot_request(state, reqwest::Method::PUT, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token)
.header("Content-Length", "0")
.send()
.await;
let done = bot_request(
state,
reqwest::Method::PUT,
format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id),
&c.discord_bot_token,
)
.header("Content-Length", "0")
.send()
.await;
match done {
Ok(r) if r.status().is_success() => report.roles_added += 1,
_ => report.failed += 1,
@@ -279,11 +340,24 @@ pub async fn sync_all(state: &AppState) -> AppResult<SyncReport> {
}
if let Some(chosen) = chosen {
let mut managed: Vec<String> = rank_roles.iter().map(|(_, role)| role.clone()).collect();
if !settings.base_role_id.is_empty() { managed.push(settings.base_role_id.clone()); }
managed.sort(); managed.dedup();
if !settings.base_role_id.is_empty() {
managed.push(settings.base_role_id.clone());
}
managed.sort();
managed.dedup();
for role in managed.into_iter().filter(|r| r != &chosen && roles.contains(r)) {
let done = bot_request(state, reqwest::Method::DELETE, format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id), &c.discord_bot_token).send().await;
match done { Ok(r) if r.status().is_success() => report.roles_removed += 1, _ => report.failed += 1 }
let done = bot_request(
state,
reqwest::Method::DELETE,
format!("{API}/guilds/{}/members/{discord_id}/roles/{role}", c.discord_guild_id),
&c.discord_bot_token,
)
.send()
.await;
match done {
Ok(r) if r.status().is_success() => report.roles_removed += 1,
_ => report.failed += 1,
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
@@ -347,7 +421,12 @@ pub async fn announce(state: &AppState) -> AppResult<u32> {
.await?;
for (uuid, who, title, desc, xp, category, at) in rows {
let mut vars = embeds::player_vars(state, &uuid, &who).await;
for (k, v) in [("achievement", embeds::plain(&title)), ("achievement_description", embeds::plain(&desc)), ("xp", xp.to_string()), ("category", embeds::plain(&category))] {
for (k, v) in [
("achievement", embeds::plain(&title)),
("achievement_description", embeds::plain(&desc)),
("xp", xp.to_string()),
("category", embeds::plain(&category)),
] {
vars.insert(k.into(), v);
}
if embeds::announce_event(state, "achievement", &vars).await.unwrap_or(false) {
@@ -357,7 +436,11 @@ pub async fn announce(state: &AppState) -> AppResult<u32> {
}
}
if s.notify_guilds {
let rows: Vec<(String, String)> = sqlx::query_as("SELECT id, created_at FROM guilds WHERE created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.guilds).fetch_all(&state.db).await?;
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT id, created_at FROM guilds WHERE created_at > ? ORDER BY created_at LIMIT 10")
.bind(&cursor.guilds)
.fetch_all(&state.db)
.await?;
for (id, at) in rows {
let vars = embeds::guild_vars(state, &id).await;
if embeds::announce_event(state, "guild", &vars).await.unwrap_or(false) {
@@ -367,11 +450,16 @@ pub async fn announce(state: &AppState) -> AppResult<u32> {
}
}
if s.notify_members {
let rows: Vec<(String, String, String)> =
sqlx::query_as("SELECT uuid, username, created_at FROM users WHERE status = 'active' AND created_at > ? ORDER BY created_at LIMIT 10").bind(&cursor.members).fetch_all(&state.db).await?;
let rows: Vec<(String, String, String)> = sqlx::query_as(
"SELECT uuid, username, created_at FROM users WHERE status = 'active' AND created_at > ? ORDER BY created_at LIMIT 10",
)
.bind(&cursor.members)
.fetch_all(&state.db)
.await?;
for (uuid, name, at) in rows {
let mut vars = embeds::player_vars(state, &uuid, &name).await;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'active'").fetch_one(&state.db).await.unwrap_or(0);
let total: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'active'").fetch_one(&state.db).await.unwrap_or(0);
vars.insert("members_total".into(), total.to_string());
if embeds::announce_event(state, "member", &vars).await.unwrap_or(false) {
sent += 1;
+6 -1
View File
@@ -14,7 +14,12 @@ use std::collections::BTreeMap;
fn check_style(s: &EmbedStyle) -> AppResult<()> {
let too_long = |t: &str, n: usize| t.chars().count() > n;
if too_long(&s.title, 600) || too_long(&s.description, 4000) || too_long(&s.footer, 600) || too_long(&s.content, 2000) || s.fields.len() > 25 {
if too_long(&s.title, 600)
|| too_long(&s.description, 4000)
|| too_long(&s.footer, 600)
|| too_long(&s.content, 2000)
|| s.fields.len() > 25
{
return Err(AppError::bad_request("a Discord message part is too long (title 256, text 4096, footer 2048, 25 fields)"));
}
for url in [&s.url, &s.thumbnail, &s.image, &s.avatar_url, &s.author_icon, &s.footer_icon] {
+178 -107
View File
@@ -10,23 +10,47 @@ use scopenet_shared::{BaltopEntry, EconomyTransaction, MarketListing, ServerEcon
use serde::Deserialize;
use serde_json::Value;
pub(crate) async fn begin_operation(state: &AppState, server_id: i64, operation_id: &str) -> AppResult<(sqlx::Transaction<'static, sqlx::Sqlite>, Option<Value>)> {
if operation_id.is_empty() || operation_id.len() > 100 { return Err(AppError::bad_request("Invalid operation ID")); }
pub(crate) async fn begin_operation(
state: &AppState,
server_id: i64,
operation_id: &str,
) -> AppResult<(sqlx::Transaction<'static, sqlx::Sqlite>, Option<Value>)> {
if operation_id.is_empty() || operation_id.len() > 100 {
return Err(AppError::bad_request("Invalid operation ID"));
}
let mut tx = state.db.begin().await?;
// The first statement obtains the write lock before reading any balances.
let inserted = sqlx::query("INSERT OR IGNORE INTO economy_operations(server_id, operation_id, response) VALUES (?, ?, '')")
.bind(server_id).bind(operation_id).execute(&mut *tx).await?.rows_affected();
.bind(server_id)
.bind(operation_id)
.execute(&mut *tx)
.await?
.rows_affected();
let previous = if inserted == 0 {
let raw: String = sqlx::query_scalar("SELECT response FROM economy_operations WHERE server_id = ? AND operation_id = ?")
.bind(server_id).bind(operation_id).fetch_one(&mut *tx).await?;
.bind(server_id)
.bind(operation_id)
.fetch_one(&mut *tx)
.await?;
Some(serde_json::from_str(&raw)?)
} else { None };
} else {
None
};
Ok((tx, previous))
}
pub(crate) async fn finish_operation(mut tx: sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, operation_id: &str, response: Value) -> AppResult<Json<Value>> {
pub(crate) async fn finish_operation(
mut tx: sqlx::Transaction<'_, sqlx::Sqlite>,
server_id: i64,
operation_id: &str,
response: Value,
) -> AppResult<Json<Value>> {
sqlx::query("UPDATE economy_operations SET response = ? WHERE server_id = ? AND operation_id = ?")
.bind(response.to_string()).bind(server_id).bind(operation_id).execute(&mut *tx).await?;
.bind(response.to_string())
.bind(server_id)
.bind(operation_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(response))
}
@@ -47,15 +71,29 @@ pub struct AdjustBalancePayload {
pub description: String,
}
pub async fn server_adjust_balance(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<AdjustBalancePayload>) -> AppResult<Json<Value>> {
if !p.delta.is_finite() || p.delta == 0.0 || p.delta.abs() > 1e12 { return Err(AppError::bad_request("Invalid amount")); }
pub async fn server_adjust_balance(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(p): Json<AdjustBalancePayload>,
) -> AppResult<Json<Value>> {
if !p.delta.is_finite() || p.delta == 0.0 || p.delta.abs() > 1e12 {
return Err(AppError::bad_request("Invalid amount"));
}
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); }
if let Some(previous) = previous {
return Ok(Json(previous));
}
ensure_balance(&mut tx, server.economy_id, &p.uuid, &p.username).await?;
let balance: Option<f64> = sqlx::query_scalar("UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ? AND balance + ? >= 0 RETURNING balance")
.bind(p.delta).bind(chrono::Utc::now().to_rfc3339()).bind(server.economy_id).bind(&p.uuid).bind(p.delta).fetch_optional(&mut *tx).await?;
let Some(balance) = balance else { return Err(AppError::bad_request("Insufficient funds")); };
let (from, from_name, to, to_name) = if p.delta > 0.0 { ("server", "Server Shop", p.uuid.as_str(), p.username.as_str()) } else { (p.uuid.as_str(), p.username.as_str(), "server", "Server Shop") };
let Some(balance) = balance else {
return Err(AppError::bad_request("Insufficient funds"));
};
let (from, from_name, to, to_name) = if p.delta > 0.0 {
("server", "Server Shop", p.uuid.as_str(), p.username.as_str())
} else {
(p.uuid.as_str(), p.username.as_str(), "server", "Server Shop")
};
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(from).bind(from_name).bind(to).bind(to_name).bind(p.delta.abs()).bind(&p.description).bind(chrono::Utc::now().to_rfc3339()).execute(&mut *tx).await?;
finish_operation(tx, server.id, &p.operation_id, serde_json::json!({"ok": true, "balance": balance})).await
@@ -64,8 +102,11 @@ pub async fn server_adjust_balance(GameServer(server): GameServer, State(state):
pub async fn server_market_read(GameServer(server): GameServer, State(state): State<AppState>) -> AppResult<Json<Vec<Value>>> {
let rows: Vec<(i64, String, String, String, i32, f64, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT m.id, m.seller_name, m.item_id, m.item_name, m.amount, m.price, m.item_data, g.tag
FROM server_market m LEFT JOIN guilds g ON g.id = m.seller_guild_id WHERE m.server_id = ? ORDER BY m.id DESC LIMIT 45")
.bind(server.id).fetch_all(&state.db).await?;
FROM server_market m LEFT JOIN guilds g ON g.id = m.seller_guild_id WHERE m.server_id = ? ORDER BY m.id DESC LIMIT 45",
)
.bind(server.id)
.fetch_all(&state.db)
.await?;
Ok(Json(rows.into_iter().map(|(id, seller, item_id, item_name, amount, price, data, guild)| serde_json::json!({"id":id,"seller_name":seller,"seller_guild":guild,"item_id":item_id,"item_name":item_name,"amount":amount,"price":price,"item_data":data})).collect()))
}
@@ -74,10 +115,7 @@ pub async fn server_market_read(GameServer(server): GameServer, State(state): St
// ---------------------------------------------------------------------------
/// 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>>> {
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
@@ -91,22 +129,14 @@ pub async fn get_my_balances(
let list = rows
.into_iter()
.map(|(sid, sname, bal)| ServerEconomyBalance {
server_id: sid,
server_name: sname,
balance: bal,
currency_symbol: "$".into(),
})
.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>>> {
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
@@ -140,10 +170,7 @@ pub async fn get_my_transactions(
}
/// 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>>> {
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
@@ -155,25 +182,14 @@ pub async fn get_server_baltop(
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.enumerate()
.map(|(i, (uuid, username, balance))| BaltopEntry {
rank: i + 1,
uuid,
username,
balance,
})
.collect();
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>>> {
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, Option<String>)> = sqlx::query_as(
"SELECT m.id, m.server_id, m.seller_uuid, m.seller_name, m.item_id, m.item_name, m.amount, m.price, m.created_at, g.tag
FROM server_market m LEFT JOIN guilds g ON g.id = m.seller_guild_id
@@ -216,7 +232,12 @@ pub struct ServerBalanceQuery {
/// A player's balance on a server (0 until they have an account there).
pub async fn balance_for(state: &AppState, server_id: i64, uuid: &str) -> AppResult<f64> {
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(server_id).bind(uuid).fetch_optional(&state.db).await?.unwrap_or(0.0))
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
.bind(server_id)
.bind(uuid)
.fetch_optional(&state.db)
.await?
.unwrap_or(0.0))
}
pub async fn server_get_balance(
@@ -227,13 +248,11 @@ pub async fn server_get_balance(
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.economy_id)
.bind(&payload.uuid)
.fetch_optional(&state.db)
.await?;
let bal_opt: Option<f64> = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
.bind(server.economy_id)
.bind(&payload.uuid)
.fetch_optional(&state.db)
.await?;
let balance = match bal_opt {
Some(b) => b,
@@ -286,13 +305,11 @@ pub async fn server_transfer(
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.economy_id)
.bind(&payload.from_uuid)
.fetch_optional(&mut *tx)
.await?;
let from_bal: Option<f64> = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
.bind(server.economy_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 {
@@ -381,10 +398,7 @@ pub async fn server_sync_balance(
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn server_baltop(
GameServer(server): GameServer,
State(state): State<AppState>,
) -> AppResult<Json<Vec<BaltopEntry>>> {
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
@@ -396,16 +410,8 @@ pub async fn server_baltop(
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
.enumerate()
.map(|(i, (uuid, username, balance))| BaltopEntry {
rank: i + 1,
uuid,
username,
balance,
})
.collect();
let list =
rows.into_iter().enumerate().map(|(i, (uuid, username, balance))| BaltopEntry { rank: i + 1, uuid, username, balance }).collect();
Ok(Json(list))
}
@@ -430,20 +436,36 @@ pub async fn server_market_list(
State(state): State<AppState>,
Json(payload): Json<MarketListPayload>,
) -> AppResult<Json<Value>> {
if !payload.price.is_finite() || payload.price <= 0.0 || payload.amount <= 0 || payload.amount > 64 { return Err(AppError::bad_request("Invalid listing")); }
if !payload.price.is_finite() || payload.price <= 0.0 || payload.amount <= 0 || payload.amount > 64 {
return Err(AppError::bad_request("Invalid listing"));
}
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); }
if let Some(previous) = previous {
return Ok(Json(previous));
}
let limit = crate::progression::load(&mut tx).await?.rules.market_max_listings;
if limit > 0 {
let mine: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server_market WHERE server_id = ? AND seller_uuid = ?").bind(server.id).bind(&payload.seller_uuid).fetch_one(&mut *tx).await?;
if mine >= limit { return Err(AppError::bad_request(format!("You can have at most {limit} market listings at once"))); }
let mine: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server_market WHERE server_id = ? AND seller_uuid = ?")
.bind(server.id)
.bind(&payload.seller_uuid)
.fetch_one(&mut *tx)
.await?;
if mine >= limit {
return Err(AppError::bad_request(format!("You can have at most {limit} market listings at once")));
}
}
let now = chrono::Utc::now().to_rfc3339();
let guild_id = if payload.as_guild {
let m = crate::routes::guild_bank::membership(&mut tx, &server, &payload.seller_uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
if !m.can_spend() { return Err(AppError::forbidden("Only guild leaders and officers can sell for the guild")); }
let m = crate::routes::guild_bank::membership(&mut tx, &server, &payload.seller_uuid)
.await?
.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
if !m.can_spend() {
return Err(AppError::forbidden("Only guild leaders and officers can sell for the guild"));
}
Some(m.guild_id)
} else { None };
} else {
None
};
let id: i64 = sqlx::query_scalar(
"INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at, item_data, seller_guild_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -482,7 +504,9 @@ pub async fn server_market_buy(
Json(payload): Json<MarketBuyPayload>,
) -> AppResult<Json<Value>> {
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
if let Some(previous) = previous { return Ok(Json(previous)); }
if let Some(previous) = previous {
return Ok(Json(previous));
}
let listing: Option<(String, String, String, String, i32, f64, Option<String>, Option<String>)> = sqlx::query_as(
"DELETE FROM server_market
WHERE id = ? AND server_id = ?
@@ -503,10 +527,16 @@ pub async fn server_market_buy(
None => None,
};
let buyer_guild = if payload.as_guild {
let m = crate::routes::guild_bank::membership(&mut tx, &server, &payload.buyer_uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
if !m.can_spend() { return Err(AppError::forbidden("Only guild leaders and officers can spend guild money")); }
let m = crate::routes::guild_bank::membership(&mut tx, &server, &payload.buyer_uuid)
.await?
.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
if !m.can_spend() {
return Err(AppError::forbidden("Only guild leaders and officers can spend guild money"));
}
Some(m)
} else { None };
} else {
None
};
ensure_balance(&mut tx, server.economy_id, &payload.buyer_uuid, &payload.buyer_name).await?;
let now = chrono::Utc::now().to_rfc3339();
@@ -514,38 +544,67 @@ pub async fn server_market_buy(
// Buyer pays: their own balance, or their guild's bank.
let new_balance = if let Some(m) = &buyer_guild {
let left = crate::routes::guild_bank::adjust_wallet(&mut tx, server.economy_id, &m.guild_id, -price).await?;
crate::routes::guild_bank::log(&mut tx, server.economy_id, &m.guild_id, &payload.buyer_uuid, "purchase", price, &format!("Market: {amount}x {item_name}")).await?;
crate::routes::guild_bank::log(
&mut tx,
server.economy_id,
&m.guild_id,
&payload.buyer_uuid,
"purchase",
price,
&format!("Market: {amount}x {item_name}"),
)
.await?;
left
} else {
let buyer_bal: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
.bind(server.economy_id).bind(&payload.buyer_uuid).fetch_optional(&mut *tx).await?.unwrap_or(0.0);
.bind(server.economy_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.economy_id).bind(&payload.buyer_uuid).execute(&mut *tx).await?;
.bind(price)
.bind(&now)
.bind(server.economy_id)
.bind(&payload.buyer_uuid)
.execute(&mut *tx)
.await?;
buyer_bal - price
};
// Seller is paid: the guild bank for guild listings, otherwise the player.
let (to_uuid, to_name) = if let Some((guild_id, tag)) = &seller_guild {
crate::routes::guild_bank::adjust_wallet(&mut tx, server.economy_id, guild_id, price).await?;
crate::routes::guild_bank::log(&mut tx, server.economy_id, guild_id, &seller_uuid, "sale", price, &format!("Market: {amount}x {item_name}")).await?;
crate::routes::guild_bank::log(
&mut tx,
server.economy_id,
guild_id,
&seller_uuid,
"sale",
price,
&format!("Market: {amount}x {item_name}"),
)
.await?;
(format!("guild:{guild_id}"), format!("[{tag}] guild bank"))
} else {
// A seller without an account first gets the usual starting balance, then the sale on top.
ensure_balance(&mut tx, server.economy_id, &seller_uuid, &seller_name).await?;
sqlx::query("UPDATE server_economy SET balance = balance + ?, username = ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
.bind(price).bind(&seller_name).bind(&now).bind(server.economy_id).bind(&seller_uuid)
.execute(&mut *tx).await?;
.bind(price)
.bind(&seller_name)
.bind(&now)
.bind(server.economy_id)
.bind(&seller_uuid)
.execute(&mut *tx)
.await?;
(seller_uuid.clone(), seller_name.clone())
};
// Delete listing
sqlx::query("DELETE FROM server_market WHERE id = ?")
.bind(payload.listing_id)
.execute(&mut *tx)
.await?;
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);
@@ -554,8 +613,14 @@ pub async fn server_market_buy(
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(server.id)
.bind(match &buyer_guild { Some(m) => format!("guild:{}", m.guild_id), None => payload.buyer_uuid.clone() })
.bind(match &buyer_guild { Some(m) => format!("[{}] guild bank", m.tag), None => payload.buyer_name.clone() })
.bind(match &buyer_guild {
Some(m) => format!("guild:{}", m.guild_id),
None => payload.buyer_uuid.clone(),
})
.bind(match &buyer_guild {
Some(m) => format!("[{}] guild bank", m.tag),
None => payload.buyer_name.clone(),
})
.bind(&to_uuid)
.bind(&to_name)
.bind(price)
@@ -564,13 +629,19 @@ pub async fn server_market_buy(
.execute(&mut *tx)
.await?;
finish_operation(tx, server.id, &payload.operation_id, serde_json::json!({
"ok": true,
"item_id": item_id,
"item_data": item_data,
"item_name": item_name,
"amount": amount,
"price": price,
"new_balance": new_balance
})).await
finish_operation(
tx,
server.id,
&payload.operation_id,
serde_json::json!({
"ok": true,
"item_id": item_id,
"item_data": item_data,
"item_name": item_name,
"amount": amount,
"price": price,
"new_balance": new_balance
}),
)
.await
}
+69 -19
View File
@@ -54,7 +54,8 @@ pub async fn templates(state: &AppState) -> AppResult<Vec<EmailTemplate>> {
Ok(if stored.is_empty() { defaults() } else { stored })
}
pub const PLACEHOLDERS: [&str; 11] = ["player", "uuid", "email", "level", "rank_title", "rank_title_suffix", "guild", "guild_tag", "brand", "panel_url", "unsubscribe_url"];
pub const PLACEHOLDERS: [&str; 11] =
["player", "uuid", "email", "level", "rank_title", "rank_title_suffix", "guild", "guild_tag", "brand", "panel_url", "unsubscribe_url"];
// ---------------------------------------------------------------------------
// Rendering
@@ -112,7 +113,8 @@ fn emphasis(s: &str) -> String {
/// Plain text to the branded HTML layout. Everything the admin or a player typed is escaped first.
pub fn html(body: &str, brand: &str, accent: &str, footer: &str) -> String {
let accent = if accent.len() == 7 && accent.starts_with('#') && accent[1..].chars().all(|c| c.is_ascii_hexdigit()) { accent } else { "#8b6cff" };
let accent =
if accent.len() == 7 && accent.starts_with('#') && accent[1..].chars().all(|c| c.is_ascii_hexdigit()) { accent } else { "#8b6cff" };
let mut content = String::new();
let mut list = false;
for raw in body.lines() {
@@ -185,15 +187,27 @@ async fn vars_for(state: &AppState, p: &Person) -> AppResult<Vars> {
let brand = store::branding(state).await?.name;
let panel = state.cfg.public_url.clone().unwrap_or_default().trim_end_matches('/').to_string();
let mut v = Vars::new();
let level: Option<(i64, Option<String>)> = sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
let level: Option<(i64, Option<String>)> =
sqlx::query_as("SELECT global_level, title FROM user_levels WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
let (lvl, title) = level.unwrap_or((1, None));
let title = plain(&title.unwrap_or_default());
let guild: Option<(String, String)> = sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
let guild: Option<(String, String)> =
sqlx::query_as("SELECT g.name, g.tag FROM guild_members m JOIN guilds g ON g.id = m.guild_id WHERE m.uuid = ?")
.bind(&p.uuid)
.fetch_optional(&state.db)
.await?;
let (gn, gt) = guild.unwrap_or_default();
for (k, val) in [
("player", plain(&p.username)), ("uuid", p.uuid.clone()), ("email", p.email.clone()), ("level", lvl.to_string()),
("rank_title_suffix", if title.is_empty() { String::new() } else { format!(" ({title})") }), ("rank_title", title),
("guild", plain(&gn)), ("guild_tag", plain(&gt)), ("brand", brand), ("panel_url", panel.clone()),
("player", plain(&p.username)),
("uuid", p.uuid.clone()),
("email", p.email.clone()),
("level", lvl.to_string()),
("rank_title_suffix", if title.is_empty() { String::new() } else { format!(" ({title})") }),
("rank_title", title),
("guild", plain(&gn)),
("guild_tag", plain(&gt)),
("brand", brand),
("panel_url", panel.clone()),
("unsubscribe_url", format!("{panel}/api/v1/email/unsubscribe?u={}&t={}", p.uuid, unsub_token(state, &p.uuid).await?)),
] {
v.insert(k.into(), val);
@@ -216,7 +230,10 @@ async fn build(state: &AppState, subject: &str, body: &str, important: bool, p:
let footer_text = if important {
format!("This message is about your {brand} account.")
} else {
format!("You are receiving this because you have an account on {brand}. <a href=\"{}\" style=\"color:#8d97b8\">Unsubscribe</a>", esc(&vars["unsubscribe_url"]))
format!(
"You are receiving this because you have an account on {brand}. <a href=\"{}\" style=\"color:#8d97b8\">Unsubscribe</a>",
esc(&vars["unsubscribe_url"])
)
};
let mut plain_text = text.clone();
if !important {
@@ -241,14 +258,22 @@ pub struct Audience {
async fn recipients(state: &AppState, a: &Audience, important: bool) -> AppResult<Vec<Person>> {
let optout = if important { "" } else { " AND u.email_optout = 0" };
let base = format!("SELECT u.uuid, u.username, u.email FROM users u WHERE u.status = 'active' AND u.email IS NOT NULL AND u.email <> ''{optout}");
let base = format!(
"SELECT u.uuid, u.username, u.email FROM users u WHERE u.status = 'active' AND u.email IS NOT NULL AND u.email <> ''{optout}"
);
let rows: Vec<(String, String, String)> = match a.kind.as_str() {
"all" => sqlx::query_as(&format!("{base} ORDER BY u.id")).fetch_all(&state.db).await?,
"group" => sqlx::query_as(&format!("{base} AND u.id IN (SELECT user_id FROM user_groups WHERE group_id = ?) ORDER BY u.id")).bind(a.group_id).fetch_all(&state.db).await?,
"group" => {
sqlx::query_as(&format!("{base} AND u.id IN (SELECT user_id FROM user_groups WHERE group_id = ?) ORDER BY u.id"))
.bind(a.group_id)
.fetch_all(&state.db)
.await?
}
"players" => {
let mut out = Vec::new();
for n in a.names.iter().take(500) {
let r: Option<(String, String, String)> = sqlx::query_as(&format!("{base} AND u.username = ? COLLATE NOCASE")).bind(n.trim()).fetch_optional(&state.db).await?;
let r: Option<(String, String, String)> =
sqlx::query_as(&format!("{base} AND u.username = ? COLLATE NOCASE")).bind(n.trim()).fetch_optional(&state.db).await?;
out.extend(r);
}
out
@@ -264,8 +289,11 @@ async fn recipients(state: &AppState, a: &Audience, important: bool) -> AppResul
pub async fn get_all(_: AdminUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
let conn = connections::settings(&state).await?;
let log: Vec<(i64, String, String, i64, i64, i64, Option<String>, String, Option<String>)> =
sqlx::query_as("SELECT id, subject, audience, total, sent, failed, last_error, started_at, finished_at FROM email_log ORDER BY id DESC LIMIT 20").fetch_all(&state.db).await?;
let log: Vec<(i64, String, String, i64, i64, i64, Option<String>, String, Option<String>)> = sqlx::query_as(
"SELECT id, subject, audience, total, sent, failed, last_error, started_at, finished_at FROM email_log ORDER BY id DESC LIMIT 20",
)
.fetch_all(&state.db)
.await?;
let groups: Vec<(i64, String)> = sqlx::query_as("SELECT id, name FROM groups ORDER BY name").fetch_all(&state.db).await?;
Ok(Json(json!({
"configured": !conn.resend_api_key.is_empty() && !conn.sender_email.is_empty(),
@@ -330,7 +358,9 @@ pub struct AudienceBody {
pub async fn audience(_: AdminUser, State(state): State<AppState>, Json(b): Json<AudienceBody>) -> AppResult<Json<Value>> {
let list = recipients(&state, &b.audience, b.important).await?;
Ok(Json(json!({ "count": list.len(), "sample": list.iter().take(5).map(|p| p.username.clone()).collect::<Vec<_>>(), "limit": MAX_RECIPIENTS })))
Ok(Json(
json!({ "count": list.len(), "sample": list.iter().take(5).map(|p| p.username.clone()).collect::<Vec<_>>(), "limit": MAX_RECIPIENTS }),
))
}
#[derive(Deserialize)]
@@ -362,11 +392,21 @@ pub async fn send(admin: AdminUser, State(state): State<AppState>, Json(b): Json
}
let label = match b.audience.kind.as_str() {
"all" => "Everyone".to_string(),
"group" => sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE id = ?").bind(b.audience.group_id).fetch_optional(&state.db).await?.map(|n| format!("Group: {n}")).unwrap_or_else(|| "A group".into()),
"group" => sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE id = ?")
.bind(b.audience.group_id)
.fetch_optional(&state.db)
.await?
.map(|n| format!("Group: {n}"))
.unwrap_or_else(|| "A group".into()),
_ => format!("{} chosen players", people.len()),
};
let log_id: i64 = sqlx::query_scalar("INSERT INTO email_log (subject, audience, total, started_at) VALUES (?, ?, ?, ?) RETURNING id")
.bind(b.subject.chars().take(200).collect::<String>()).bind(label).bind(people.len() as i64).bind(crate::db::now()).fetch_one(&state.db).await?;
.bind(b.subject.chars().take(200).collect::<String>())
.bind(label)
.bind(people.len() as i64)
.bind(crate::db::now())
.fetch_one(&state.db)
.await?;
let total = people.len();
let bg = state.clone();
tokio::spawn(async move {
@@ -376,8 +416,16 @@ pub async fn send(admin: AdminUser, State(state): State<AppState>, Json(b): Json
Err(e) => Err(e),
};
match outcome {
Ok(()) => { let _ = sqlx::query("UPDATE email_log SET sent = sent + 1 WHERE id = ?").bind(log_id).execute(&bg.db).await; }
Err(e) => { let _ = sqlx::query("UPDATE email_log SET failed = failed + 1, last_error = ? WHERE id = ?").bind(e.message.chars().take(200).collect::<String>()).bind(log_id).execute(&bg.db).await; }
Ok(()) => {
let _ = sqlx::query("UPDATE email_log SET sent = sent + 1 WHERE id = ?").bind(log_id).execute(&bg.db).await;
}
Err(e) => {
let _ = sqlx::query("UPDATE email_log SET failed = failed + 1, last_error = ? WHERE id = ?")
.bind(e.message.chars().take(200).collect::<String>())
.bind(log_id)
.execute(&bg.db)
.await;
}
}
tokio::time::sleep(Duration::from_millis(150)).await;
}
@@ -397,7 +445,9 @@ pub struct UnsubQuery {
}
pub async fn unsubscribe(State(state): State<AppState>, Query(q): Query<UnsubQuery>) -> Html<String> {
let page = |msg: &str| Html(format!("<!doctype html><meta name=viewport content='width=device-width'><body style=\"font-family:system-ui;background:#0f1220;color:#e9ecff;display:grid;place-items:center;min-height:100vh;margin:0\"><div style=\"max-width:420px;padding:32px;text-align:center\"><h2>{}</h2></div></body>", esc(msg)));
let page = |msg: &str| {
Html(format!("<!doctype html><meta name=viewport content='width=device-width'><body style=\"font-family:system-ui;background:#0f1220;color:#e9ecff;display:grid;place-items:center;min-height:100vh;margin:0\"><div style=\"max-width:420px;padding:32px;text-align:center\"><h2>{}</h2></div></body>", esc(msg)))
};
let ok = matches!(unsub_token(&state, &q.u).await, Ok(t) if t == q.t);
if !ok {
return page("That link isn't valid.");
+39 -7
View File
@@ -71,7 +71,15 @@ pub async fn adjust_wallet(conn: &mut SqliteConnection, server_id: i64, guild_id
balance.ok_or_else(|| AppError::bad_request("The guild bank doesn't have enough funds"))
}
pub async fn log(conn: &mut SqliteConnection, server_id: i64, guild_id: &str, actor: &str, kind: &str, amount: f64, note: &str) -> AppResult<()> {
pub async fn log(
conn: &mut SqliteConnection,
server_id: i64,
guild_id: &str,
actor: &str,
kind: &str,
amount: f64,
note: &str,
) -> AppResult<()> {
sqlx::query("INSERT INTO guild_wallet_transactions(server_id, guild_id, actor_uuid, kind, amount, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
.bind(server_id)
.bind(guild_id)
@@ -86,7 +94,12 @@ pub async fn log(conn: &mut SqliteConnection, server_id: i64, guild_id: &str, ac
}
async fn player_balance(conn: &mut SqliteConnection, server_id: i64, uuid: &str) -> AppResult<f64> {
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(server_id).bind(uuid).fetch_optional(&mut *conn).await?.unwrap_or(0.0))
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
.bind(server_id)
.bind(uuid)
.fetch_optional(&mut *conn)
.await?
.unwrap_or(0.0))
}
#[derive(Deserialize)]
@@ -134,7 +147,11 @@ pub struct Transfer {
pub action: String,
}
pub async fn server_bank_transfer(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Transfer>) -> AppResult<Json<Value>> {
pub async fn server_bank_transfer(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(p): Json<Transfer>,
) -> AppResult<Json<Value>> {
valid_amount(p.amount)?;
let withdraw = match p.action.as_str() {
"deposit" => false,
@@ -164,8 +181,18 @@ pub async fn server_bank_transfer(GameServer(server): GameServer, State(state):
let my_balance = moved.ok_or_else(|| AppError::bad_request("Insufficient funds"))?;
let balance = adjust_wallet(&mut tx, server.economy_id, &m.guild_id, guild_delta).await?;
log(&mut tx, server.economy_id, &m.guild_id, &p.uuid, &p.action, p.amount, "").await?;
let message = if withdraw { format!("Withdrew ${:.2} from [{}] bank.", p.amount, m.tag) } else { format!("Deposited ${:.2} into [{}] bank.", p.amount, m.tag) };
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "balance": balance, "my_balance": my_balance, "message": message })).await
let message = if withdraw {
format!("Withdrew ${:.2} from [{}] bank.", p.amount, m.tag)
} else {
format!("Deposited ${:.2} into [{}] bank.", p.amount, m.tag)
};
finish_operation(
tx,
server.id,
&p.operation_id,
json!({ "ok": true, "balance": balance, "my_balance": my_balance, "message": message }),
)
.await
}
#[derive(Deserialize)]
@@ -179,7 +206,11 @@ pub struct Credit {
}
/// Shop sales made with `/guild sell`: the proceeds go to the guild bank instead of the seller.
pub async fn server_bank_credit(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Credit>) -> AppResult<Json<Value>> {
pub async fn server_bank_credit(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(p): Json<Credit>,
) -> AppResult<Json<Value>> {
valid_amount(p.amount)?;
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
if let Some(previous) = previous {
@@ -226,7 +257,8 @@ pub async fn server_bank_pay(GameServer(server): GameServer, State(state): State
let balance = adjust_wallet(&mut tx, server.economy_id, &m.guild_id, -p.amount).await?;
adjust_wallet(&mut tx, server.economy_id, &target_id, p.amount).await?;
let note = p.note.chars().take(60).collect::<String>();
log(&mut tx, server.economy_id, &m.guild_id, &p.uuid, "transfer_out", p.amount, &format!("to [{target_tag}] {target_name} {note}")).await?;
log(&mut tx, server.economy_id, &m.guild_id, &p.uuid, "transfer_out", p.amount, &format!("to [{target_tag}] {target_name} {note}"))
.await?;
log(&mut tx, server.economy_id, &target_id, &p.uuid, "transfer_in", p.amount, &format!("from [{}] {} {note}", m.tag, m.name)).await?;
let message = format!("Paid ${:.2} from [{}] to [{}] {}.", p.amount, m.tag, target_tag, target_name);
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "balance": balance, "message": message })).await
+282 -72
View File
@@ -147,74 +147,152 @@ pub async fn guild_wallet(
) -> AppResult<Json<Value>> {
let member: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_members gm JOIN guilds g ON g.id=gm.guild_id JOIN game_servers s ON s.instance_id=g.instance_id WHERE gm.guild_id=? AND gm.uuid=? AND s.id=?)")
.bind(&guild_id).bind(&auth.uuid).bind(query.server_id).fetch_one(&state.db).await?;
if !member { return Err(AppError::forbidden("Guild membership is required")); }
if !member {
return Err(AppError::forbidden("Guild membership is required"));
}
let sid = crate::routes::servers::economy_scope(&state.db, query.server_id).await?;
let balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE guild_id=? AND server_id=?")
.bind(&guild_id).bind(sid).fetch_optional(&state.db).await?.unwrap_or(0.0);
.bind(&guild_id)
.bind(sid)
.fetch_optional(&state.db)
.await?
.unwrap_or(0.0);
let rows: Vec<(i64, String, String, f64, String, String)> = sqlx::query_as("SELECT id,actor_uuid,kind,amount,created_at,note FROM guild_wallet_transactions WHERE guild_id=? AND server_id=? ORDER BY id DESC LIMIT 30")
.bind(&guild_id).bind(sid).fetch_all(&state.db).await?;
let role: String = sqlx::query_scalar("SELECT gm.role FROM guild_members gm WHERE gm.guild_id=? AND gm.uuid=?")
.bind(&guild_id).bind(&auth.uuid).fetch_one(&state.db).await?;
.bind(&guild_id)
.bind(&auth.uuid)
.fetch_one(&state.db)
.await?;
// A player's first transfer creates their balance at 1000, so show that until then.
let my_balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id=? AND uuid=?")
.bind(sid).bind(&auth.uuid).fetch_optional(&state.db).await?.unwrap_or(1000.0);
Ok(Json(serde_json::json!({"balance":balance,"role":role,"my_balance":my_balance,"currency_symbol":"$","transactions":rows.into_iter().map(|(id,actor,kind,amount,created_at,note)| serde_json::json!({"id":id,"actor_uuid":actor,"kind":kind,"amount":amount,"created_at":created_at,"note":note})).collect::<Vec<_>>()})))
.bind(sid)
.bind(&auth.uuid)
.fetch_optional(&state.db)
.await?
.unwrap_or(1000.0);
Ok(Json(
serde_json::json!({"balance":balance,"role":role,"my_balance":my_balance,"currency_symbol":"$","transactions":rows.into_iter().map(|(id,actor,kind,amount,created_at,note)| serde_json::json!({"id":id,"actor_uuid":actor,"kind":kind,"amount":amount,"created_at":created_at,"note":note})).collect::<Vec<_>>()}),
))
}
pub async fn guild_wallet_deposit(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<GuildWalletTransfer>) -> AppResult<Json<Value>> {
pub async fn guild_wallet_deposit(
auth: AuthUser,
Path(guild_id): Path<String>,
State(state): State<AppState>,
Json(p): Json<GuildWalletTransfer>,
) -> AppResult<Json<Value>> {
wallet_transfer(&state, &auth, &guild_id, p, false).await
}
pub async fn guild_wallet_withdraw(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<GuildWalletTransfer>) -> AppResult<Json<Value>> {
pub async fn guild_wallet_withdraw(
auth: AuthUser,
Path(guild_id): Path<String>,
State(state): State<AppState>,
Json(p): Json<GuildWalletTransfer>,
) -> AppResult<Json<Value>> {
wallet_transfer(&state, &auth, &guild_id, p, true).await
}
async fn wallet_transfer(state: &AppState, auth: &AuthUser, guild_id: &str, p: GuildWalletTransfer, withdraw: bool) -> AppResult<Json<Value>> {
async fn wallet_transfer(
state: &AppState,
auth: &AuthUser,
guild_id: &str,
p: GuildWalletTransfer,
withdraw: bool,
) -> AppResult<Json<Value>> {
if !p.amount.is_finite() || p.amount <= 0.0 || p.amount > 1e9 || (p.amount * 100.0).fract().abs() > 0.00001 {
return Err(AppError::bad_request("Amount must be between 0.01 and 1,000,000,000 with at most two decimals"));
}
let role: Option<String> = sqlx::query_scalar("SELECT gm.role FROM guild_members gm JOIN guilds g ON g.id=gm.guild_id JOIN game_servers s ON s.instance_id=g.instance_id WHERE gm.guild_id=? AND gm.uuid=? AND s.id=?")
.bind(guild_id).bind(&auth.uuid).bind(p.server_id).fetch_optional(&state.db).await?;
let Some(role) = role else { return Err(AppError::forbidden("Guild membership is required")); };
if withdraw && role != "leader" && role != "officer" { return Err(AppError::forbidden("Only guild leaders and officers can withdraw")); }
let Some(role) = role else {
return Err(AppError::forbidden("Guild membership is required"));
};
if withdraw && role != "leader" && role != "officer" {
return Err(AppError::forbidden("Only guild leaders and officers can withdraw"));
}
// Servers in one economy group share balances and guild banks.
let mut p = p;
p.server_id = crate::routes::servers::economy_scope(&state.db, p.server_id).await?;
let now = chrono::Utc::now().to_rfc3339();
let mut tx = state.db.begin().await?;
// The first write serializes transfers across concurrent requests.
sqlx::query("INSERT INTO guild_wallets(server_id,guild_id,balance,updated_at) VALUES(?,?,0,?) ON CONFLICT(server_id,guild_id) DO NOTHING")
.bind(p.server_id).bind(guild_id).bind(&now).execute(&mut *tx).await?;
sqlx::query(
"INSERT INTO guild_wallets(server_id,guild_id,balance,updated_at) VALUES(?,?,0,?) ON CONFLICT(server_id,guild_id) DO NOTHING",
)
.bind(p.server_id)
.bind(guild_id)
.bind(&now)
.execute(&mut *tx)
.await?;
sqlx::query("INSERT INTO server_economy(server_id,uuid,username,balance,updated_at) VALUES(?,?,?,1000,?) ON CONFLICT(server_id,uuid) DO NOTHING")
.bind(p.server_id).bind(&auth.uuid).bind(&auth.username).bind(&now).execute(&mut *tx).await?;
let source = if withdraw {
sqlx::query("UPDATE guild_wallets SET balance=balance-?,updated_at=? WHERE server_id=? AND guild_id=? AND balance>=?")
.bind(p.amount).bind(&now).bind(p.server_id).bind(guild_id).bind(p.amount).execute(&mut *tx).await?
.bind(p.amount)
.bind(&now)
.bind(p.server_id)
.bind(guild_id)
.bind(p.amount)
.execute(&mut *tx)
.await?
} else {
sqlx::query("UPDATE server_economy SET balance=balance-?,updated_at=? WHERE server_id=? AND uuid=? AND balance>=?")
.bind(p.amount).bind(&now).bind(p.server_id).bind(&auth.uuid).bind(p.amount).execute(&mut *tx).await?
.bind(p.amount)
.bind(&now)
.bind(p.server_id)
.bind(&auth.uuid)
.bind(p.amount)
.execute(&mut *tx)
.await?
};
if source.rows_affected() == 0 { return Err(AppError::bad_request("Insufficient funds")); }
if source.rows_affected() == 0 {
return Err(AppError::bad_request("Insufficient funds"));
}
if withdraw {
sqlx::query("UPDATE server_economy SET balance=balance+?,updated_at=? WHERE server_id=? AND uuid=?")
.bind(p.amount).bind(&now).bind(p.server_id).bind(&auth.uuid).execute(&mut *tx).await?;
.bind(p.amount)
.bind(&now)
.bind(p.server_id)
.bind(&auth.uuid)
.execute(&mut *tx)
.await?;
} else {
sqlx::query("UPDATE guild_wallets SET balance=balance+?,updated_at=? WHERE server_id=? AND guild_id=?")
.bind(p.amount).bind(&now).bind(p.server_id).bind(guild_id).execute(&mut *tx).await?;
.bind(p.amount)
.bind(&now)
.bind(p.server_id)
.bind(guild_id)
.execute(&mut *tx)
.await?;
}
sqlx::query("INSERT INTO guild_wallet_transactions(server_id,guild_id,actor_uuid,kind,amount,created_at) VALUES(?,?,?,?,?,?)")
.bind(p.server_id).bind(guild_id).bind(&auth.uuid).bind(if withdraw { "withdraw" } else { "deposit" }).bind(p.amount).bind(&now).execute(&mut *tx).await?;
.bind(p.server_id)
.bind(guild_id)
.bind(&auth.uuid)
.bind(if withdraw { "withdraw" } else { "deposit" })
.bind(p.amount)
.bind(&now)
.execute(&mut *tx)
.await?;
let balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE server_id=? AND guild_id=?")
.bind(p.server_id).bind(guild_id).fetch_one(&mut *tx).await?;
.bind(p.server_id)
.bind(guild_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({"balance":balance})))
}
/// Refuse a new member when the guild is at the admin-set member limit (0 = no limit).
pub async fn check_room(conn: &mut sqlx::SqliteConnection, guild_id: &str) -> AppResult<()> {
let limit: i64 = sqlx::query_scalar("SELECT COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_guild_max_members'), 0)").fetch_one(&mut *conn).await?;
let limit: i64 = sqlx::query_scalar("SELECT COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_guild_max_members'), 0)")
.fetch_one(&mut *conn)
.await?;
if limit > 0 {
let members: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM guild_members WHERE guild_id = ?").bind(guild_id).fetch_one(&mut *conn).await?;
let members: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM guild_members WHERE guild_id = ?").bind(guild_id).fetch_one(&mut *conn).await?;
if members >= limit {
return Err(AppError::bad_request(format!("That guild is full ({limit} members)")));
}
@@ -481,14 +559,30 @@ pub struct AddMemberPayload {
}
#[derive(Deserialize)]
pub struct JoinRequestPayload { #[serde(default)] pub message: String }
pub struct JoinRequestPayload {
#[serde(default)]
pub message: String,
}
pub async fn request_join(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>, Json(payload): Json<JoinRequestPayload>) -> AppResult<Json<Value>> {
let instance: Option<String> = sqlx::query_scalar("SELECT instance_id FROM guilds WHERE id = ?").bind(&id).fetch_optional(&state.db).await?;
pub async fn request_join(
auth: AuthUser,
Path(id): Path<String>,
State(state): State<AppState>,
Json(payload): Json<JoinRequestPayload>,
) -> AppResult<Json<Value>> {
let instance: Option<String> =
sqlx::query_scalar("SELECT instance_id FROM guilds WHERE id = ?").bind(&id).fetch_optional(&state.db).await?;
let instance = instance.ok_or_else(|| AppError::not_found("guild not found"))?;
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(&instance).fetch_one(&state.db).await?;
if in_guild { return Err(AppError::bad_request("Leave your current guild before requesting to join another")); }
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(&instance)
.fetch_one(&state.db)
.await?;
if in_guild {
return Err(AppError::bad_request("Leave your current guild before requesting to join another"));
}
let name: String = sqlx::query_scalar("SELECT username FROM users WHERE id = ?").bind(auth.id).fetch_one(&state.db).await?;
sqlx::query("INSERT INTO guild_join_requests(guild_id,uuid,name,message,created_at) VALUES(?,?,?,?,?) ON CONFLICT(guild_id,uuid) DO UPDATE SET message=excluded.message,created_at=excluded.created_at")
.bind(&id).bind(&auth.uuid).bind(&name).bind(payload.message.trim().chars().take(300).collect::<String>()).bind(crate::db::now()).execute(&state.db).await?;
@@ -496,25 +590,51 @@ pub async fn request_join(auth: AuthUser, Path(id): Path<String>, State(state):
}
pub async fn list_join_requests(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
if !guild_can(&state, &id, &auth.uuid, "invite").await? { return Err(AppError::forbidden("Your guild role cannot review requests")); }
let rows: Vec<(String,String,String,String)> = sqlx::query_as("SELECT uuid,name,message,created_at FROM guild_join_requests WHERE guild_id=? ORDER BY created_at DESC")
.bind(&id).fetch_all(&state.db).await?;
Ok(Json(json!(rows.into_iter().map(|(uuid,name,message,created_at)| json!({"uuid":uuid,"name":name,"message":message,"created_at":created_at})).collect::<Vec<_>>())))
if !guild_can(&state, &id, &auth.uuid, "invite").await? {
return Err(AppError::forbidden("Your guild role cannot review requests"));
}
let rows: Vec<(String, String, String, String)> =
sqlx::query_as("SELECT uuid,name,message,created_at FROM guild_join_requests WHERE guild_id=? ORDER BY created_at DESC")
.bind(&id)
.fetch_all(&state.db)
.await?;
Ok(Json(json!(rows
.into_iter()
.map(|(uuid, name, message, created_at)| json!({"uuid":uuid,"name":name,"message":message,"created_at":created_at}))
.collect::<Vec<_>>())))
}
#[derive(Deserialize)]
pub struct JoinDecision { pub accept: bool }
pub struct JoinDecision {
pub accept: bool,
}
pub async fn respond_join_request(auth: AuthUser, Path((id, uuid)): Path<(String,String)>, State(state): State<AppState>, Json(decision): Json<JoinDecision>) -> AppResult<Json<Value>> {
if !guild_can(&state, &id, &auth.uuid, "invite").await? { return Err(AppError::forbidden("Your guild role cannot review requests")); }
pub async fn respond_join_request(
auth: AuthUser,
Path((id, uuid)): Path<(String, String)>,
State(state): State<AppState>,
Json(decision): Json<JoinDecision>,
) -> AppResult<Json<Value>> {
if !guild_can(&state, &id, &auth.uuid, "invite").await? {
return Err(AppError::forbidden("Your guild role cannot review requests"));
}
let mut tx = state.db.begin().await?;
let name: Option<String> = sqlx::query_scalar("SELECT name FROM guild_join_requests WHERE guild_id=? AND uuid=?")
.bind(&id).bind(&uuid).fetch_optional(&mut *tx).await?;
.bind(&id)
.bind(&uuid)
.fetch_optional(&mut *tx)
.await?;
let name = name.ok_or_else(|| AppError::not_found("request not found"))?;
if decision.accept {
check_room(&mut tx, &id).await?;
sqlx::query("INSERT INTO guild_members(guild_id,uuid,name,role,joined_at) VALUES(?,?,?,'member',?)")
.bind(&id).bind(&uuid).bind(&name).bind(crate::db::now()).execute(&mut *tx).await.map_err(|_| AppError::bad_request("Player has already joined a guild"))?;
.bind(&id)
.bind(&uuid)
.bind(&name)
.bind(crate::db::now())
.execute(&mut *tx)
.await
.map_err(|_| AppError::bad_request("Player has already joined a guild"))?;
sqlx::query("DELETE FROM guild_join_requests WHERE uuid=?").bind(&uuid).execute(&mut *tx).await?;
} else {
sqlx::query("DELETE FROM guild_join_requests WHERE guild_id=? AND uuid=?").bind(&id).bind(&uuid).execute(&mut *tx).await?;
@@ -526,79 +646,166 @@ pub async fn respond_join_request(auth: AuthUser, Path((id, uuid)): Path<(String
#[derive(Deserialize)]
pub struct GuildRoleInput {
pub name: String,
#[serde(default)] pub priority: i64,
#[serde(default)] pub can_invite: bool,
#[serde(default)] pub can_kick: bool,
#[serde(default)] pub can_claim: bool,
#[serde(default)] pub can_post: bool,
#[serde(default)] pub can_manage: bool,
#[serde(default)]
pub priority: i64,
#[serde(default)]
pub can_invite: bool,
#[serde(default)]
pub can_kick: bool,
#[serde(default)]
pub can_claim: bool,
#[serde(default)]
pub can_post: bool,
#[serde(default)]
pub can_manage: bool,
}
async fn guild_can(state: &AppState, guild_id: &str, uuid: &str, action: &str) -> AppResult<bool> {
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id=? AND uuid=?")
.bind(guild_id).bind(uuid).fetch_optional(&state.db).await?;
let Some(role) = role else { return Ok(false); };
if role == "leader" || role == "officer" { return Ok(true); }
if role == "member" { return Ok(matches!(action, "claim" | "post")); }
let column = match action { "invite" => "can_invite", "kick" => "can_kick", "claim" => "can_claim", "post" => "can_post", "manage" => "can_manage", _ => return Ok(false) };
.bind(guild_id)
.bind(uuid)
.fetch_optional(&state.db)
.await?;
let Some(role) = role else {
return Ok(false);
};
if role == "leader" || role == "officer" {
return Ok(true);
}
if role == "member" {
return Ok(matches!(action, "claim" | "post"));
}
let column = match action {
"invite" => "can_invite",
"kick" => "can_kick",
"claim" => "can_claim",
"post" => "can_post",
"manage" => "can_manage",
_ => return Ok(false),
};
let allowed: Option<i64> = sqlx::query_scalar(&format!("SELECT {column} FROM guild_roles WHERE guild_id=? AND name=?"))
.bind(guild_id).bind(&role).fetch_optional(&state.db).await?;
.bind(guild_id)
.bind(&role)
.fetch_optional(&state.db)
.await?;
Ok(allowed.unwrap_or(0) != 0)
}
async fn require_guild_leader(state: &AppState, guild_id: &str, uuid: &str) -> AppResult<()> {
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id=? AND uuid=?")
.bind(guild_id).bind(uuid).fetch_optional(&state.db).await?;
if role.as_deref() != Some("leader") { return Err(AppError::forbidden("Only the guild leader can manage roles")); }
.bind(guild_id)
.bind(uuid)
.fetch_optional(&state.db)
.await?;
if role.as_deref() != Some("leader") {
return Err(AppError::forbidden("Only the guild leader can manage roles"));
}
Ok(())
}
pub async fn list_guild_roles(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
let member: 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 !member { return Err(AppError::forbidden("Guild membership is required")); }
.bind(&id)
.bind(&auth.uuid)
.fetch_one(&state.db)
.await?;
if !member {
return Err(AppError::forbidden("Guild membership is required"));
}
let roles: Vec<(i64,String,i64,i64,i64,i64,i64,i64)> = sqlx::query_as("SELECT id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage FROM guild_roles WHERE guild_id=? ORDER BY priority DESC,name")
.bind(&id).fetch_all(&state.db).await?;
Ok(Json(json!(roles.into_iter().map(|(id,name,priority,invite,kick,claim,post,manage)| json!({"id":id,"name":name,"priority":priority,"can_invite":invite!=0,"can_kick":kick!=0,"can_claim":claim!=0,"can_post":post!=0,"can_manage":manage!=0})).collect::<Vec<_>>())))
}
pub async fn create_guild_role(auth: AuthUser, Path(id): Path<String>, State(state): State<AppState>, Json(role): Json<GuildRoleInput>) -> AppResult<Json<Value>> {
pub async fn create_guild_role(
auth: AuthUser,
Path(id): Path<String>,
State(state): State<AppState>,
Json(role): Json<GuildRoleInput>,
) -> AppResult<Json<Value>> {
require_guild_leader(&state, &id, &auth.uuid).await?;
let name = role.name.trim();
if name.len() < 2 || name.len() > 24 || !name.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '_')
|| ["leader","officer","member"].iter().any(|r| r.eq_ignore_ascii_case(name)) {
return Err(AppError::bad_request("Role names must be 2–24 letters, numbers, spaces, hyphens or underscores, and cannot be a built-in role"));
if name.len() < 2
|| name.len() > 24
|| !name.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '-' || c == '_')
|| ["leader", "officer", "member"].iter().any(|r| r.eq_ignore_ascii_case(name))
{
return Err(AppError::bad_request(
"Role names must be 2–24 letters, numbers, spaces, hyphens or underscores, and cannot be a built-in role",
));
}
let duplicate: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_roles WHERE guild_id=? AND name=? COLLATE NOCASE)")
.bind(&id).bind(name).fetch_one(&state.db).await?;
if duplicate { return Err(AppError::conflict("A role with this name already exists")); }
let inserted = sqlx::query("INSERT INTO guild_roles(guild_id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage) VALUES(?,?,?,?,?,?,?,?)")
.bind(&id).bind(name).bind(role.priority.clamp(0,100)).bind(role.can_invite).bind(role.can_kick).bind(role.can_claim).bind(role.can_post).bind(role.can_manage)
.execute(&state.db).await?;
.bind(&id)
.bind(name)
.fetch_one(&state.db)
.await?;
if duplicate {
return Err(AppError::conflict("A role with this name already exists"));
}
let inserted = sqlx::query(
"INSERT INTO guild_roles(guild_id,name,priority,can_invite,can_kick,can_claim,can_post,can_manage) VALUES(?,?,?,?,?,?,?,?)",
)
.bind(&id)
.bind(name)
.bind(role.priority.clamp(0, 100))
.bind(role.can_invite)
.bind(role.can_kick)
.bind(role.can_claim)
.bind(role.can_post)
.bind(role.can_manage)
.execute(&state.db)
.await?;
Ok(Json(json!({"ok":true,"id":inserted.last_insert_rowid()})))
}
#[derive(Deserialize)]
pub struct AssignRole { pub role: String }
pub struct AssignRole {
pub role: String,
}
pub async fn assign_guild_role(auth: AuthUser, Path((id, uuid)): Path<(String,String)>, State(state): State<AppState>, Json(input): Json<AssignRole>) -> AppResult<Json<Value>> {
pub async fn assign_guild_role(
auth: AuthUser,
Path((id, uuid)): Path<(String, String)>,
State(state): State<AppState>,
Json(input): Json<AssignRole>,
) -> AppResult<Json<Value>> {
require_guild_leader(&state, &id, &auth.uuid).await?;
if input.role == "leader" { return Err(AppError::bad_request("Use leadership transfer to assign the leader role")); }
if input.role == "leader" {
return Err(AppError::bad_request("Use leadership transfer to assign the leader role"));
}
if input.role != "member" && input.role != "officer" {
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM guild_roles WHERE guild_id=? AND name=?)")
.bind(&id).bind(&input.role).fetch_one(&state.db).await?;
if !exists { return Err(AppError::bad_request("Role does not belong to this guild")); }
.bind(&id)
.bind(&input.role)
.fetch_one(&state.db)
.await?;
if !exists {
return Err(AppError::bad_request("Role does not belong to this guild"));
}
}
let done = sqlx::query("UPDATE guild_members SET role=? WHERE guild_id=? AND uuid=? AND role<>'leader'")
.bind(&input.role).bind(&id).bind(&uuid).execute(&state.db).await?;
if done.rows_affected() == 0 { return Err(AppError::not_found("member not found or is guild leader")); }
.bind(&input.role)
.bind(&id)
.bind(&uuid)
.execute(&state.db)
.await?;
if done.rows_affected() == 0 {
return Err(AppError::not_found("member not found or is guild leader"));
}
Ok(Json(json!({"ok":true})))
}
pub async fn delete_guild_role(auth: AuthUser, Path((id, role_id)): Path<(String,i64)>, State(state): State<AppState>) -> AppResult<Json<Value>> {
pub async fn delete_guild_role(
auth: AuthUser,
Path((id, role_id)): Path<(String, i64)>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
require_guild_leader(&state, &id, &auth.uuid).await?;
let name: Option<String> = sqlx::query_scalar("SELECT name FROM guild_roles WHERE guild_id=? AND id=?")
.bind(&id).bind(role_id).fetch_optional(&state.db).await?;
.bind(&id)
.bind(role_id)
.fetch_optional(&state.db)
.await?;
let name = name.ok_or_else(|| AppError::not_found("role not found"))?;
let mut tx = state.db.begin().await?;
sqlx::query("UPDATE guild_members SET role='member' WHERE guild_id=? AND role=?").bind(&id).bind(&name).execute(&mut *tx).await?;
@@ -1061,7 +1268,8 @@ pub async fn server_claim_index(
State(state): State<AppState>,
Json(payload): Json<ClaimIndexPayload>,
) -> AppResult<Json<Value>> {
let revision: String = sqlx::query_scalar("SELECT value FROM kv WHERE key = 'guild_rev'").fetch_optional(&state.db).await?.unwrap_or_else(|| "0".into());
let revision: String =
sqlx::query_scalar("SELECT value FROM kv WHERE key = 'guild_rev'").fetch_optional(&state.db).await?.unwrap_or_else(|| "0".into());
if payload.revision.as_deref() == Some(revision.as_str()) {
return Ok(Json(json!({ "revision": revision, "unchanged": true })));
}
@@ -1166,7 +1374,9 @@ pub async fn server_claim_chunk(
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>"));
};
if !guild_can(&state, &guild_id, &payload.uuid, "claim").await? { return Err(AppError::forbidden("Your guild role cannot claim land")); }
if !guild_can(&state, &guild_id, &payload.uuid, "claim").await? {
return Err(AppError::forbidden("Your guild role cannot claim land"));
}
let max_claims: i64 =
sqlx::query_scalar("SELECT (guilds.max_claims + COALESCE((SELECT SUM(b.claim_chunks) FROM player_bonuses b JOIN guild_members m ON m.uuid = b.uuid WHERE m.guild_id = guilds.id), 0) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_member'), 0) * MAX(0, (SELECT COUNT(*) FROM guild_members m2 WHERE m2.guild_id = guilds.id) - 1) + COALESCE((SELECT CAST(value AS INTEGER) FROM kv WHERE key = 'rule_claims_per_level'), 0) * MAX(0, guilds.level - 1)) FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&state.db).await.unwrap_or(16);
+54 -14
View File
@@ -61,12 +61,18 @@ pub struct GroupMapping {
pub luckperms_group: String,
}
pub async fn set_group_mapping(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>, Json(m): Json<GroupMapping>) -> AppResult<Json<Value>> {
pub async fn set_group_mapping(
_: AdminUser,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(m): Json<GroupMapping>,
) -> AppResult<Json<Value>> {
let lp = m.luckperms_group.trim();
if lp.len() > 64 || !lp.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) {
return Err(AppError::bad_request("LuckPerms group names are letters, digits, _ - and ."));
}
let done = sqlx::query("UPDATE groups SET luckperms_group = ? WHERE id = ?").bind(lp.to_lowercase()).bind(id).execute(&state.db).await?;
let done =
sqlx::query("UPDATE groups SET luckperms_group = ? WHERE id = ?").bind(lp.to_lowercase()).bind(id).execute(&state.db).await?;
if done.rows_affected() == 0 {
return Err(AppError::not_found("group not found"));
}
@@ -129,7 +135,8 @@ fn clean(s: &str, max: usize) -> String {
async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Value) -> AppResult<Value> {
let settings = load_settings(conn).await?;
let players: Vec<LpPlayer> = serde_json::from_value(data.get("players").cloned().unwrap_or(json!([]))).unwrap_or_default();
let mapped: Vec<(i64, String)> = sqlx::query_as("SELECT id, luckperms_group FROM groups WHERE luckperms_group <> ''").fetch_all(&mut *conn).await?;
let mapped: Vec<(i64, String)> =
sqlx::query_as("SELECT id, luckperms_group FROM groups WHERE luckperms_group <> ''").fetch_all(&mut *conn).await?;
let level_groups: Vec<(i64, String)> = sqlx::query_as("SELECT level_req, json_extract(reward_data, '$.luckperms_group') FROM level_rewards WHERE level_type='global' AND reward_type='title' AND json_extract(reward_data, '$.luckperms_group') IS NOT NULL AND json_extract(reward_data, '$.luckperms_group') <> '' ORDER BY level_req DESC")
.fetch_all(&mut *conn).await?;
let now = crate::db::now();
@@ -159,7 +166,11 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
.execute(&mut *conn)
.await?;
if !level_groups.is_empty() {
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?").bind(&uuid).fetch_optional(&mut *conn).await?.unwrap_or(0);
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid=?")
.bind(&uuid)
.fetch_optional(&mut *conn)
.await?
.unwrap_or(0);
let level = crate::progression::level_from_xp(xp).0;
let target = level_groups.iter().find(|(required, _)| *required <= level).map(|(_, group)| group.to_lowercase());
let mut add = Vec::new();
@@ -167,21 +178,37 @@ async fn apply_luckperms(conn: &mut SqliteConnection, server_id: i64, data: &Val
for (_, group) in &level_groups {
let key = group.to_lowercase();
if target.as_deref() == Some(key.as_str()) {
if !groups.contains(&key) { add.push(key); }
} else if groups.contains(&key) { remove.push(key); }
if !groups.contains(&key) {
add.push(key);
}
} else if groups.contains(&key) {
remove.push(key);
}
}
if !add.is_empty() || !remove.is_empty() {
level_assign.push(json!({"uuid":uuid,"add":add,"remove":remove}));
}
if !add.is_empty() || !remove.is_empty() { level_assign.push(json!({"uuid":uuid,"add":add,"remove":remove})); }
}
if settings.luckperms_sync == "off" || mapped.is_empty() {
continue;
}
let user_id: Option<i64> = sqlx::query_scalar("SELECT id FROM users WHERE uuid = ? AND status = 'active'").bind(&uuid).fetch_optional(&mut *conn).await?;
let user_id: Option<i64> =
sqlx::query_scalar("SELECT id FROM users WHERE uuid = ? AND status = 'active'").bind(&uuid).fetch_optional(&mut *conn).await?;
let Some(user_id) = user_id else { continue };
let member_of: HashSet<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&mut *conn).await?.into_iter().collect();
let member_of: HashSet<i64> = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?")
.bind(user_id)
.fetch_all(&mut *conn)
.await?
.into_iter()
.collect();
let lp_set: HashSet<String> = groups.iter().cloned().collect();
let plan = plan(&settings.luckperms_sync, &lp_set, &mapped, &member_of);
for gid in &plan.panel_add {
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)").bind(user_id).bind(gid).execute(&mut *conn).await?;
sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)")
.bind(user_id)
.bind(gid)
.execute(&mut *conn)
.await?;
}
for gid in &plan.panel_remove {
sqlx::query("DELETE FROM user_groups WHERE user_id = ? AND group_id = ?").bind(user_id).bind(gid).execute(&mut *conn).await?;
@@ -245,7 +272,10 @@ pub async fn server_report(GameServer(server): GameServer, State(state): State<A
pub async fn admin_server(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
get_server(&state, id).await?;
let rows: Vec<(String, String, String, String)> =
sqlx::query_as("SELECT name, version, data, updated_at FROM server_integrations WHERE server_id = ? ORDER BY name").bind(id).fetch_all(&state.db).await?;
sqlx::query_as("SELECT name, version, data, updated_at FROM server_integrations WHERE server_id = ? ORDER BY name")
.bind(id)
.fetch_all(&state.db)
.await?;
let list: Vec<Value> = rows
.into_iter()
.map(|(name, version, data, updated_at)| json!({ "name": name, "version": version, "data": serde_json::from_str::<Value>(&data).unwrap_or(Value::Null), "updated_at": updated_at }))
@@ -297,12 +327,22 @@ pub async fn notify(conn: &mut SqliteConnection, server_id: Option<i64>, kind: &
/// Take (and delete) what is waiting for this server. Oldest first, bounded.
pub async fn take_notifications(conn: &mut SqliteConnection, server_id: i64) -> AppResult<Vec<Value>> {
let rows: Vec<(i64, String, String, String, String)> =
sqlx::query_as("SELECT id, kind, uuid, payload, created_at FROM server_notifications WHERE server_id = ? ORDER BY id LIMIT 100").bind(server_id).fetch_all(&mut *conn).await?;
sqlx::query_as("SELECT id, kind, uuid, payload, created_at FROM server_notifications WHERE server_id = ? ORDER BY id LIMIT 100")
.bind(server_id)
.fetch_all(&mut *conn)
.await?;
if let Some(last) = rows.last().map(|r| r.0) {
sqlx::query("DELETE FROM server_notifications WHERE server_id = ? AND id <= ?").bind(server_id).bind(last).execute(&mut *conn).await?;
sqlx::query("DELETE FROM server_notifications WHERE server_id = ? AND id <= ?")
.bind(server_id)
.bind(last)
.execute(&mut *conn)
.await?;
}
// Stale entries (a server that was offline for days) aren't worth replaying.
sqlx::query("DELETE FROM server_notifications WHERE created_at < ?").bind((chrono::Utc::now() - chrono::Duration::hours(6)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)).execute(&mut *conn).await?;
sqlx::query("DELETE FROM server_notifications WHERE created_at < ?")
.bind((chrono::Utc::now() - chrono::Duration::hours(6)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.execute(&mut *conn)
.await?;
Ok(rows.into_iter().map(|(_, kind, uuid, payload, at)| json!({ "kind": kind, "uuid": uuid, "data": serde_json::from_str::<Value>(&payload).unwrap_or(Value::Null), "at": at })).collect())
}
+25 -46
View File
@@ -46,11 +46,7 @@ pub struct FaqItem {
impl Default for FaqItem {
fn default() -> Self {
Self {
id: "".into(),
question: "".into(),
answer: "".into(),
}
Self { id: "".into(), question: "".into(), answer: "".into() }
}
}
@@ -83,23 +79,22 @@ pub struct LandingTheme {
impl Default for LandingTheme {
fn default() -> Self {
Self {
background: "#090a0f".into(), surface: "#151620".into(), accent: "#6d6af5".into(),
text: "#f1f2f6".into(), muted: "#8b8f9a".into(), max_width: 1140, radius: 14,
font: "Inter".into(), footer_text: "Your worlds, one click away.".into(),
background: "#090a0f".into(),
surface: "#151620".into(),
accent: "#6d6af5".into(),
text: "#f1f2f6".into(),
muted: "#8b8f9a".into(),
max_width: 1140,
radius: 14,
font: "Inter".into(),
footer_text: "Your worlds, one click away.".into(),
}
}
}
impl Default for LandingBlock {
fn default() -> Self {
Self {
id: "".into(),
block_type: "hero".into(),
enabled: true,
title: None,
subtitle: None,
options: serde_json::json!({}),
}
Self { id: "".into(), block_type: "hero".into(), enabled: true, title: None, subtitle: None, options: serde_json::json!({}) }
}
}
@@ -192,7 +187,9 @@ pub async fn public_landing(State(state): State<AppState>) -> AppResult<Json<Lan
}
// If server ip is empty, try to populate from first server
if cfg.server_ip.is_empty() {
if let Ok(Some(first_server)) = sqlx::query_scalar::<_, String>("SELECT address FROM game_servers ORDER BY id ASC LIMIT 1").fetch_optional(&state.db).await {
if let Ok(Some(first_server)) =
sqlx::query_scalar::<_, String>("SELECT address FROM game_servers ORDER BY id ASC LIMIT 1").fetch_optional(&state.db).await
{
cfg.server_ip = first_server;
}
}
@@ -203,7 +200,11 @@ pub async fn admin_get_landing(_: AdminUser, State(state): State<AppState>) -> A
Ok(Json(get_config(&state).await?))
}
pub async fn admin_put_landing(_: AdminUser, State(state): State<AppState>, Json(cfg): Json<LandingConfig>) -> AppResult<Json<LandingConfig>> {
pub async fn admin_put_landing(
_: AdminUser,
State(state): State<AppState>,
Json(cfg): Json<LandingConfig>,
) -> AppResult<Json<LandingConfig>> {
if cfg.blocks.len() > 100 || cfg.custom_css.len() > 100_000 {
return Err(AppError::bad_request("landing page exceeds the editor limits"));
}
@@ -217,11 +218,7 @@ pub async fn admin_put_landing(_: AdminUser, State(state): State<AppState>, Json
Ok(Json(cfg))
}
pub async fn upload_launcher(
_: AdminUser,
State(state): State<AppState>,
mut form: Multipart,
) -> AppResult<Json<LandingConfig>> {
pub async fn upload_launcher(_: AdminUser, State(state): State<AppState>, mut form: Multipart) -> AppResult<Json<LandingConfig>> {
let mut platform = String::from("windows");
let mut label = String::from("Windows Installer");
let mut filename = String::new();
@@ -249,10 +246,8 @@ pub async fn upload_launcher(
}
// Sanitize filename to prevent directory traversal
let safe_name = PathBuf::from(&filename)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| format!("launcher-{platform}"));
let safe_name =
PathBuf::from(&filename).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| format!("launcher-{platform}"));
let dir = state.cfg.downloads_dir();
tokio::fs::create_dir_all(&dir).await?;
@@ -265,14 +260,7 @@ pub async fn upload_launcher(
let mut cfg = get_config(&state).await?;
let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let download_entry = HostedDownload {
platform: platform.clone(),
label,
filename: safe_name,
file_url,
size,
uploaded_at: ts,
};
let download_entry = HostedDownload { platform: platform.clone(), label, filename: safe_name, file_url, size, uploaded_at: ts };
if let Some(idx) = cfg.hosted_downloads.iter().position(|d| d.platform == platform) {
cfg.hosted_downloads[idx] = download_entry;
@@ -284,10 +272,7 @@ pub async fn upload_launcher(
Ok(Json(cfg))
}
pub async fn download_launcher(
State(state): State<AppState>,
Path(platform): Path<String>,
) -> AppResult<Response> {
pub async fn download_launcher(State(state): State<AppState>, Path(platform): Path<String>) -> AppResult<Response> {
let cfg = get_config(&state).await?;
let entry = cfg.hosted_downloads.iter().find(|d| d.platform == platform || d.platform == "all");
@@ -307,13 +292,7 @@ pub async fn download_launcher(
} else {
"application/octet-stream"
};
return Ok((
[
(header::CONTENT_TYPE, mime),
(header::CONTENT_DISPOSITION, cd.as_str()),
],
Body::from(bytes),
).into_response());
return Ok(([(header::CONTENT_TYPE, mime), (header::CONTENT_DISPOSITION, cd.as_str())], Body::from(bytes)).into_response());
}
}
+55 -52
View File
@@ -15,11 +15,10 @@ pub use crate::progression::{level_from_xp, xp_for_level};
/// Achievement XP is credited automatically via the `achievement_xp` DB trigger;
/// this function only processes level-up rewards (titles, badges, item entitlements).
pub async fn grant_rewards(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, now: &str) -> AppResult<()> {
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
.bind(uuid).fetch_optional(&mut **tx).await?.unwrap_or(0);
let xp: i64 =
sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&mut **tx).await?.unwrap_or(0);
let level = level_from_xp(xp).0;
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?")
.bind(level).bind(uuid).execute(&mut **tx).await?;
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?").bind(level).bind(uuid).execute(&mut **tx).await?;
let rewards: Vec<(i64, String, Option<i64>, String, String, String)> = sqlx::query_as(
"SELECT r.id, r.level_type, r.server_id, r.reward_type, r.reward_name, r.reward_data FROM level_rewards r
WHERE (r.level_type = 'global' AND r.level_req <= ?)
@@ -28,8 +27,15 @@ pub async fn grant_rewards(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &
.bind(level).bind(uuid).fetch_all(&mut **tx).await?;
for (id, scope, server_id, kind, name, data) in rewards {
let inserted = sqlx::query("INSERT OR IGNORE INTO granted_rewards(uuid, reward_id, granted_at) VALUES (?, ?, ?)")
.bind(uuid).bind(id).bind(now).execute(&mut **tx).await?.rows_affected();
if inserted == 0 { continue; }
.bind(uuid)
.bind(id)
.bind(now)
.execute(&mut **tx)
.await?
.rows_affected();
if inserted == 0 {
continue;
}
crate::rewards::enqueue(&mut **tx, uuid, "level_reward", &id.to_string()).await?;
match kind.as_str() {
"title" if scope == "global" => {
@@ -37,15 +43,24 @@ pub async fn grant_rewards(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &
}
"title" => {
sqlx::query("UPDATE server_levels SET rank_name = ? WHERE server_id = ? AND uuid = ?")
.bind(&name).bind(server_id).bind(uuid).execute(&mut **tx).await?;
.bind(&name)
.bind(server_id)
.bind(uuid)
.execute(&mut **tx)
.await?;
}
"badge" | "profile_badge" => {
let raw: String = sqlx::query_scalar("SELECT badges FROM user_levels WHERE uuid = ?")
.bind(uuid).fetch_optional(&mut **tx).await?.unwrap_or_else(|| "[]".into());
.bind(uuid)
.fetch_optional(&mut **tx)
.await?
.unwrap_or_else(|| "[]".into());
let mut badges: Vec<String> = serde_json::from_str(&raw).unwrap_or_default();
let data: Value = serde_json::from_str(&data).unwrap_or_default();
let badge = data["badge"].as_str().unwrap_or(&name).to_string();
if !badges.contains(&badge) { badges.push(badge); }
if !badges.contains(&badge) {
badges.push(badge);
}
sqlx::query("INSERT INTO user_levels(uuid, badges, updated_at) VALUES (?, ?, ?) ON CONFLICT(uuid) DO UPDATE SET badges = excluded.badges")
.bind(uuid).bind(serde_json::to_string(&badges)?).bind(now).execute(&mut **tx).await?;
}
@@ -71,28 +86,21 @@ pub struct RewardRow {
}
/// Get current user's global and server levels.
pub async fn get_my_levels(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<UserLevelInfo>> {
pub async fn get_my_levels(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<UserLevelInfo>> {
get_user_levels_by_uuid(&state, &auth.uuid).await.map(Json)
}
/// Public / Profile lookup for player levels.
pub async fn get_player_levels(
Path(uuid): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<UserLevelInfo>> {
pub async fn get_player_levels(Path(uuid): Path<String>, State(state): State<AppState>) -> AppResult<Json<UserLevelInfo>> {
get_user_levels_by_uuid(&state, &uuid).await.map(Json)
}
pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<UserLevelInfo> {
let row: Option<(i64, i64, Option<String>, String)> = sqlx::query_as(
"SELECT global_xp, global_level, title, badges FROM user_levels WHERE uuid = ?",
)
.bind(uuid)
.fetch_optional(&state.db)
.await?;
let row: Option<(i64, i64, Option<String>, String)> =
sqlx::query_as("SELECT global_xp, global_level, title, badges FROM user_levels WHERE uuid = ?")
.bind(uuid)
.fetch_optional(&state.db)
.await?;
let (global_xp, _db_lvl, title, badges_json) = row.unwrap_or((0, 1, None, "[]".into()));
let (global_level, current_level_xp, next_level_xp, progress_pct) = level_from_xp(global_xp);
@@ -155,13 +163,11 @@ pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<
})
.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));
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(),
@@ -181,9 +187,7 @@ pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult<
}
/// Global leveling leaderboard.
pub async fn get_level_leaderboard(
State(state): State<AppState>,
) -> AppResult<Json<Vec<Value>>> {
pub async fn get_level_leaderboard(State(state): State<AppState>) -> AppResult<Json<Vec<Value>>> {
let rows: Vec<(String, String, i64, i64, Option<String>)> = sqlx::query_as(
"SELECT u.uuid, u.username, ul.global_xp, ul.global_level, ul.title
FROM user_levels ul
@@ -217,9 +221,7 @@ pub async fn get_level_leaderboard(
}
/// Get available rewards.
pub async fn list_rewards(
State(state): State<AppState>,
) -> AppResult<Json<Vec<Value>>> {
pub async fn list_rewards(State(state): State<AppState>) -> AppResult<Json<Vec<Value>>> {
let rows: Vec<(i64, String, Option<i64>, i64, String, String, String, String)> = sqlx::query_as(
"SELECT id, level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at
FROM level_rewards
@@ -232,11 +234,9 @@ 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")
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" => "👑",
@@ -284,7 +284,8 @@ pub struct RewardPayload {
fn validate_rank_mapping(data: &serde_json::Map<String, Value>) -> AppResult<()> {
if data.get("discord_role_id").is_some_and(|v| !v.is_string() && !v.is_null())
|| data.get("luckperms_group").is_some_and(|v| !v.is_string() && !v.is_null()) {
|| data.get("luckperms_group").is_some_and(|v| !v.is_string() && !v.is_null())
{
return Err(AppError::bad_request("Rank role mappings must be text"));
}
let discord = data.get("discord_role_id").and_then(Value::as_str).unwrap_or("");
@@ -309,12 +310,18 @@ async fn refresh_global_titles(state: &AppState) -> AppResult<()> {
.bind(level).fetch_optional(&mut *tx).await?;
sqlx::query("UPDATE user_levels SET title=? WHERE uuid=?").bind(title).bind(uuid).execute(&mut *tx).await?;
}
let members: Vec<(i64, String, i64)> = sqlx::query_as("SELECT server_id, uuid, server_xp FROM server_levels").fetch_all(&mut *tx).await?;
let members: Vec<(i64, String, i64)> =
sqlx::query_as("SELECT server_id, uuid, server_xp FROM server_levels").fetch_all(&mut *tx).await?;
for (server_id, uuid, xp) in members {
let level = level_from_xp(xp).0;
let rank: Option<String> = sqlx::query_scalar("SELECT reward_name FROM level_rewards WHERE level_type='server' AND server_id=? AND reward_type='title' AND level_req<=? ORDER BY level_req DESC,id DESC LIMIT 1")
.bind(server_id).bind(level).fetch_optional(&mut *tx).await?;
sqlx::query("UPDATE server_levels SET rank_name=? WHERE server_id=? AND uuid=?").bind(rank).bind(server_id).bind(uuid).execute(&mut *tx).await?;
sqlx::query("UPDATE server_levels SET rank_name=? WHERE server_id=? AND uuid=?")
.bind(rank)
.bind(server_id)
.bind(uuid)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
@@ -416,16 +423,12 @@ pub async fn admin_update_reward(
}
/// Admin delete reward.
pub async fn admin_delete_reward(
_admin: AdminUser,
Path(id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM reward_bundles WHERE source_type = 'level_reward' AND source_id = ?").bind(id.to_string()).execute(&state.db).await?;
sqlx::query("DELETE FROM level_rewards WHERE id = ?")
.bind(id)
pub async fn admin_delete_reward(_admin: AdminUser, Path(id): Path<i64>, State(state): State<AppState>) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM reward_bundles WHERE source_type = 'level_reward' AND source_id = ?")
.bind(id.to_string())
.execute(&state.db)
.await?;
sqlx::query("DELETE FROM level_rewards WHERE id = ?").bind(id).execute(&state.db).await?;
refresh_global_titles(&state).await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+139 -45
View File
@@ -22,12 +22,25 @@ fn ago(secs: i64) -> String {
}
async fn online_on(conn: &mut SqliteConnection, server_id: i64, uuid: &str) -> AppResult<bool> {
Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE server_id = ? AND uuid = ?)").bind(server_id).bind(uuid).fetch_one(&mut *conn).await?)
Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE server_id = ? AND uuid = ?)")
.bind(server_id)
.bind(uuid)
.fetch_one(&mut *conn)
.await?)
}
/// Keep only printable characters, collapse whitespace, and cut to `max`.
fn clean(text: &str, max: usize) -> String {
text.chars().filter(|c| c.is_whitespace() || !c.is_control()).map(|c| if c.is_whitespace() { ' ' } else { c }).collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ").chars().take(max).collect()
text.chars()
.filter(|c| c.is_whitespace() || !c.is_control())
.map(|c| if c.is_whitespace() { ' ' } else { c })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(max)
.collect()
}
#[derive(Deserialize)]
@@ -40,13 +53,21 @@ pub struct MapAction {
}
/// `POST /servers/{id}/map/actions`: a signed-in player acts on another player they clicked on the map.
pub async fn map_action(auth: AuthUser, Path(server_id): Path<i64>, State(state): State<AppState>, Json(a): Json<MapAction>) -> AppResult<Json<Value>> {
pub async fn map_action(
auth: AuthUser,
Path(server_id): Path<i64>,
State(state): State<AppState>,
Json(a): Json<MapAction>,
) -> AppResult<Json<Value>> {
get_server(&state, server_id).await?;
if a.target_uuid == auth.uuid {
return Err(AppError::bad_request("That's you"));
}
let mut conn = state.db.acquire().await?;
let active: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ? AND status = 'active')").bind(&a.target_uuid).fetch_one(&mut *conn).await?;
let active: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ? AND status = 'active')")
.bind(&a.target_uuid)
.fetch_one(&mut *conn)
.await?;
if !active {
return Err(AppError::not_found("Player not found"));
}
@@ -77,16 +98,18 @@ pub async fn map_action(auth: AuthUser, Path(server_id): Path<i64>, State(state)
}
_ => return Err(AppError::bad_request("unknown action")),
};
sqlx::query("INSERT INTO server_actions (server_id, kind, from_uuid, from_name, to_uuid, text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
.bind(server_id)
.bind(&a.action)
.bind(&auth.uuid)
.bind(&auth.username)
.bind(&a.target_uuid)
.bind(&text)
.bind(crate::db::now())
.execute(&mut *conn)
.await?;
sqlx::query(
"INSERT INTO server_actions (server_id, kind, from_uuid, from_name, to_uuid, text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(server_id)
.bind(&a.action)
.bind(&auth.uuid)
.bind(&auth.username)
.bind(&a.target_uuid)
.bind(&text)
.bind(crate::db::now())
.execute(&mut *conn)
.await?;
Ok(Json(json!({ "ok": true })))
}
@@ -122,7 +145,8 @@ pub async fn poll_actions(GameServer(server): GameServer, State(state): State<Ap
/// Queue a line of chat for a player on every server where they are online.
pub async fn tell_player(conn: &mut SqliteConnection, to_uuid: &str, text: &str) -> AppResult<()> {
let servers: Vec<i64> = sqlx::query_scalar("SELECT server_id FROM server_online WHERE uuid = ?").bind(to_uuid).fetch_all(&mut *conn).await?;
let servers: Vec<i64> =
sqlx::query_scalar("SELECT server_id FROM server_online WHERE uuid = ?").bind(to_uuid).fetch_all(&mut *conn).await?;
for id in servers {
sqlx::query("INSERT INTO server_actions (server_id, kind, to_uuid, text, created_at) VALUES (?, 'notify', ?, ?, ?)")
.bind(id)
@@ -140,22 +164,42 @@ pub async fn tell_player(conn: &mut SqliteConnection, to_uuid: &str, text: &str)
// ---------------------------------------------------------------------------
/// Invite `target_uuid` to `guild_id` on behalf of `inviter_uuid` (who must lead or officiate it).
pub async fn create_invite(state: &AppState, inviter_uuid: &str, inviter_name: &str, guild_id: &str, target_uuid: &str) -> AppResult<Value> {
pub async fn create_invite(
state: &AppState,
inviter_uuid: &str,
inviter_name: &str,
guild_id: &str,
target_uuid: &str,
) -> AppResult<Value> {
if inviter_uuid == target_uuid {
return Err(AppError::bad_request("You can't invite yourself"));
}
let mut conn = state.db.acquire().await?;
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?").bind(guild_id).bind(inviter_uuid).fetch_optional(&mut *conn).await?;
let role: Option<String> = sqlx::query_scalar("SELECT role FROM guild_members WHERE guild_id = ? AND uuid = ?")
.bind(guild_id)
.bind(inviter_uuid)
.fetch_optional(&mut *conn)
.await?;
let allowed = match role.as_deref() {
Some("leader" | "officer") => true,
Some(custom) => sqlx::query_scalar::<_, i64>("SELECT can_invite FROM guild_roles WHERE guild_id=? AND name=?")
.bind(guild_id).bind(custom).fetch_optional(&mut *conn).await?.unwrap_or(0) != 0,
Some(custom) => {
sqlx::query_scalar::<_, i64>("SELECT can_invite FROM guild_roles WHERE guild_id=? AND name=?")
.bind(guild_id)
.bind(custom)
.fetch_optional(&mut *conn)
.await?
.unwrap_or(0)
!= 0
}
None => false,
};
if !allowed {
return Err(AppError::forbidden("Your guild role cannot invite players"));
}
let target: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ? AND status = 'active'").bind(target_uuid).fetch_optional(&mut *conn).await?;
let target: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ? AND status = 'active'")
.bind(target_uuid)
.fetch_optional(&mut *conn)
.await?;
let target_name = target.ok_or_else(|| AppError::not_found("Player not found"))?;
let (name, tag, instance): (String, String, String) =
sqlx::query_as("SELECT name, tag, instance_id FROM guilds WHERE id = ?").bind(guild_id).fetch_one(&mut *conn).await?;
@@ -178,15 +222,21 @@ pub async fn create_invite(state: &AppState, inviter_uuid: &str, inviter_name: &
return Err(AppError::new(axum::http::StatusCode::TOO_MANY_REQUESTS, "Too many invitations; wait a minute"));
}
// One open invitation per guild and player.
sqlx::query("DELETE FROM guild_invites WHERE guild_id = ? AND target_uuid = ? AND status = 'pending'").bind(guild_id).bind(target_uuid).execute(&mut *conn).await?;
let id: i64 = sqlx::query_scalar("INSERT INTO guild_invites (guild_id, inviter_uuid, inviter_name, target_uuid, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id")
sqlx::query("DELETE FROM guild_invites WHERE guild_id = ? AND target_uuid = ? AND status = 'pending'")
.bind(guild_id)
.bind(inviter_uuid)
.bind(inviter_name)
.bind(target_uuid)
.bind(crate::db::now())
.fetch_one(&mut *conn)
.execute(&mut *conn)
.await?;
let id: i64 = sqlx::query_scalar(
"INSERT INTO guild_invites (guild_id, inviter_uuid, inviter_name, target_uuid, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id",
)
.bind(guild_id)
.bind(inviter_uuid)
.bind(inviter_name)
.bind(target_uuid)
.bind(crate::db::now())
.fetch_one(&mut *conn)
.await?;
tell_player(&mut conn, target_uuid, &format!("\u{a7}e{inviter_name} \u{a7}ainvited you to join \u{a7}6[{tag}] {name}\u{a7}a. \u{a7}7Type \u{a7}e/guild accept \u{a7}7or \u{a7}c/guild decline\u{a7}7, or answer in the launcher.")).await?;
Ok(json!({ "ok": true, "id": id, "player": target_name }))
}
@@ -196,7 +246,12 @@ pub struct InvitePayload {
pub uuid: String,
}
pub async fn send_invite(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<InvitePayload>) -> AppResult<Json<Value>> {
pub async fn send_invite(
auth: AuthUser,
Path(guild_id): Path<String>,
State(state): State<AppState>,
Json(p): Json<InvitePayload>,
) -> AppResult<Json<Value>> {
Ok(Json(create_invite(&state, &auth.uuid, &auth.username, &guild_id, &p.uuid).await?))
}
@@ -224,20 +279,38 @@ pub async fn my_invites(auth: AuthUser, State(state): State<AppState>) -> AppRes
pub async fn respond(state: &AppState, uuid: &str, name: &str, invite: Option<i64>, tag: Option<&str>, accept: bool) -> AppResult<Value> {
let mut tx = state.db.begin().await?;
let row: Option<(i64, String)> = match (invite, tag) {
(Some(id), _) => sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE id = ? AND target_uuid = ? AND status = 'pending'").bind(id).bind(uuid).fetch_optional(&mut *tx).await?,
(None, Some(tag)) => sqlx::query_as(
"SELECT i.id, i.guild_id FROM guild_invites i JOIN guilds g ON g.id = i.guild_id
(Some(id), _) => {
sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE id = ? AND target_uuid = ? AND status = 'pending'")
.bind(id)
.bind(uuid)
.fetch_optional(&mut *tx)
.await?
}
(None, Some(tag)) => {
sqlx::query_as(
"SELECT i.id, i.guild_id FROM guild_invites i JOIN guilds g ON g.id = i.guild_id
WHERE i.target_uuid = ? AND i.status = 'pending' AND g.tag = ? COLLATE NOCASE ORDER BY i.id DESC LIMIT 1",
)
.bind(uuid)
.bind(tag)
.fetch_optional(&mut *tx)
.await?,
(None, None) => sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE target_uuid = ? AND status = 'pending' ORDER BY id DESC LIMIT 1").bind(uuid).fetch_optional(&mut *tx).await?,
)
.bind(uuid)
.bind(tag)
.fetch_optional(&mut *tx)
.await?
}
(None, None) => {
sqlx::query_as("SELECT id, guild_id FROM guild_invites WHERE target_uuid = ? AND status = 'pending' ORDER BY id DESC LIMIT 1")
.bind(uuid)
.fetch_optional(&mut *tx)
.await?
}
};
let Some((id, guild_id)) = row else { return Err(AppError::not_found("You have no open guild invitation")) };
sqlx::query("UPDATE guild_invites SET status = ? WHERE id = ?").bind(if accept { "accepted" } else { "declined" }).bind(id).execute(&mut *tx).await?;
let (gname, gtag): (String, String) = sqlx::query_as("SELECT name, tag FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&mut *tx).await?;
sqlx::query("UPDATE guild_invites SET status = ? WHERE id = ?")
.bind(if accept { "accepted" } else { "declined" })
.bind(id)
.execute(&mut *tx)
.await?;
let (gname, gtag): (String, String) =
sqlx::query_as("SELECT name, tag FROM guilds WHERE id = ?").bind(&guild_id).fetch_one(&mut *tx).await?;
if accept {
crate::routes::guilds::check_room(&mut tx, &guild_id).await?;
let res = sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, ?, 'member', ?)")
@@ -251,7 +324,11 @@ pub async fn respond(state: &AppState, uuid: &str, name: &str, invite: Option<i6
// The database refuses a second guild on the same instance.
return Err(AppError::bad_request("You're already in a guild here. Leave it first."));
}
sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, 'ach_guild_initiate', ?)").bind(uuid).bind(crate::db::now()).execute(&mut *tx).await?;
sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, 'ach_guild_initiate', ?)")
.bind(uuid)
.bind(crate::db::now())
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(json!({ "ok": true, "accepted": accept, "guild": gname, "tag": gtag }))
@@ -279,10 +356,18 @@ pub struct ServerInvite {
pub accept: bool,
}
pub async fn server_invite_send(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
let inviter: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
pub async fn server_invite_send(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(p): Json<ServerInvite>,
) -> AppResult<Json<Value>> {
let inviter: Option<String> =
sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
let inviter = inviter.ok_or_else(|| AppError::not_found("Account not found"))?;
let target: Option<String> = sqlx::query_scalar("SELECT uuid FROM users WHERE username = ? COLLATE NOCASE").bind(p.target.trim()).fetch_optional(&state.db).await?;
let target: Option<String> = sqlx::query_scalar("SELECT uuid FROM users WHERE username = ? COLLATE NOCASE")
.bind(p.target.trim())
.fetch_optional(&state.db)
.await?;
let target = target.ok_or_else(|| AppError::not_found("No player with that name"))?;
let guild: Option<String> = sqlx::query_scalar(
"SELECT g.id FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? AND g.instance_id = ? AND gm.role IN ('leader', 'officer')",
@@ -295,12 +380,21 @@ pub async fn server_invite_send(GameServer(server): GameServer, State(state): St
Ok(Json(create_invite(&state, &p.uuid, &inviter, &guild, &target).await?))
}
pub async fn server_invites(GameServer(_): GameServer, State(state): State<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
pub async fn server_invites(
GameServer(_): GameServer,
State(state): State<AppState>,
Json(p): Json<ServerInvite>,
) -> AppResult<Json<Value>> {
Ok(Json(json!({ "invites": pending_for(&state, &p.uuid).await? })))
}
pub async fn server_invite_respond(GameServer(_): GameServer, State(state): State<AppState>, Json(p): Json<ServerInvite>) -> AppResult<Json<Value>> {
let name: Option<String> = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
pub async fn server_invite_respond(
GameServer(_): GameServer,
State(state): State<AppState>,
Json(p): Json<ServerInvite>,
) -> AppResult<Json<Value>> {
let name: Option<String> =
sqlx::query_scalar("SELECT username FROM users WHERE uuid = ?").bind(&p.uuid).fetch_optional(&state.db).await?;
let name = name.ok_or_else(|| AppError::not_found("Account not found"))?;
let tag = if p.tag.trim().is_empty() { None } else { Some(p.tag.trim().to_string()) };
Ok(Json(respond(&state, &p.uuid, &name, None, tag.as_deref(), p.accept).await?))
+6 -6
View File
@@ -4,24 +4,24 @@ pub mod activity;
pub mod admin;
pub mod connections;
pub mod dev_api;
pub mod economy;
pub mod guild_bank;
pub mod guilds;
pub mod discord;
pub mod discord_studio;
pub mod economy;
pub mod emails;
pub mod guild_bank;
pub mod guilds;
pub mod integrations;
pub mod landing;
pub mod worldmap;
pub mod map;
pub mod leveling;
pub mod rewards;
pub mod map;
pub mod meta;
pub mod progression;
pub mod public;
pub mod quests;
pub mod rewards;
pub mod servers;
pub mod social;
pub mod worldmap;
use crate::state::AppState;
use axum::extract::DefaultBodyLimit;
+113 -160
View File
@@ -30,15 +30,24 @@ fn action_matches(target: &str, action: &str) -> bool {
let Some(target) = target.strip_prefix("action:") else { return false };
let (target_action, target_dimension) = target.split_once('@').map_or((target, None), |(a, d)| (a, Some(d)));
let (actual_action, actual_dimension) = action.split_once('@').map_or((action, None), |(a, d)| (a, Some(d)));
if target_dimension.is_some_and(|d| Some(d) != actual_dimension) { return false; }
if target_dimension.is_some_and(|d| Some(d) != actual_dimension) {
return false;
}
let Some((target_kind, materials)) = target_action.split_once(':') else { return false };
let Some((actual_kind, material)) = actual_action.split_once(':') else { return false };
if target_kind != actual_kind { return false; }
if target_kind != actual_kind {
return false;
}
materials.split('|').any(|pattern| {
if pattern == "*" { true }
else if let Some(suffix) = pattern.strip_prefix('*') { material.ends_with(suffix) }
else if let Some(prefix) = pattern.strip_suffix('*') { material.starts_with(prefix) }
else { material == pattern }
if pattern == "*" {
true
} else if let Some(suffix) = pattern.strip_prefix('*') {
material.ends_with(suffix)
} else if let Some(prefix) = pattern.strip_suffix('*') {
material.starts_with(prefix)
} else {
material == pattern
}
})
}
@@ -59,18 +68,27 @@ mod action_tests {
/// Advance quests that require a particular block, mob, or dimension rather
/// than a generic stat counter.
pub async fn advance_action_quests(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, uuid: &str, action: &str, count: i64, now: &str,
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
uuid: &str,
action: &str,
count: i64,
now: &str,
) -> AppResult<()> {
if count <= 0 { return Ok(()); }
let rows: Vec<(String, String, String, i64)> = sqlx::query_as(
"SELECT id, period, target_stat, target_count FROM quests WHERE enabled = 1 AND target_stat LIKE 'action:%'",
).fetch_all(&mut **tx).await?;
if count <= 0 {
return Ok(());
}
let rows: Vec<(String, String, String, i64)> =
sqlx::query_as("SELECT id, period, target_stat, target_count FROM quests WHERE enabled = 1 AND target_stat LIKE 'action:%'")
.fetch_all(&mut **tx)
.await?;
let daily = current_daily_key();
let weekly = current_weekly_key();
let cfg = crate::progression::load(&mut **tx).await?;
let mut assigned: std::collections::HashMap<&str, Vec<String>> = std::collections::HashMap::new();
for (id, period, target, required) in rows {
if !action_matches(&target, action) { continue; }
if !action_matches(&target, action) {
continue;
}
let key = if period == "weekly" { &weekly } else { &daily };
// Only quests this player was given for the period make progress.
let period_name = if period == "weekly" { "weekly" } else { "daily" };
@@ -78,7 +96,9 @@ pub async fn advance_action_quests(
let ids = crate::progression::assigned_quest_ids(&mut **tx, &cfg, uuid, period_name, key).await?;
assigned.insert(period_name, ids);
}
if !assigned[period_name].contains(&id) { continue; }
if !assigned[period_name].contains(&id) {
continue;
}
sqlx::query(
"INSERT INTO user_quests (user_uuid, quest_id, period_key, progress, completed, claimed, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?)
@@ -87,17 +107,21 @@ pub async fn advance_action_quests(
completed = CASE WHEN progress + excluded.progress >= ? THEN 1 ELSE completed END,
updated_at = excluded.updated_at",
)
.bind(uuid).bind(&id).bind(key).bind(count).bind(count >= required).bind(now).bind(required)
.execute(&mut **tx).await?;
.bind(uuid)
.bind(&id)
.bind(key)
.bind(count)
.bind(count >= required)
.bind(now)
.bind(required)
.execute(&mut **tx)
.await?;
}
Ok(())
}
/// Get all active daily and weekly quests for the authenticated user with live progress.
pub async fn get_my_quests(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<UserQuest>>> {
pub async fn get_my_quests(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<Vec<UserQuest>>> {
let daily_key = current_daily_key();
let weekly_key = current_weekly_key();
@@ -143,102 +167,68 @@ pub async fn get_my_quests(
let list = rows
.into_iter()
.filter(|r| if r.3 == "weekly" { weekly_ids.contains(&r.0) } else { daily_ids.contains(&r.0) })
.map(
|(
id,
title,
desc,
period,
cat,
stat,
target,
xp,
icon,
enabled,
progress_opt,
completed_opt,
claimed_opt,
period_key_opt,
)| {
let quest_period_is_weekly = period == "weekly";
let progress = progress_opt.unwrap_or(0);
let completed = completed_opt.unwrap_or(progress >= target);
let claimed = claimed_opt.unwrap_or(false);
let pkey = period_key_opt.unwrap_or_else(|| {
if period == "weekly" {
weekly_key.clone()
} else {
daily_key.clone()
}
});
.map(|(id, title, desc, period, cat, stat, target, xp, icon, enabled, progress_opt, completed_opt, claimed_opt, period_key_opt)| {
let quest_period_is_weekly = period == "weekly";
let progress = progress_opt.unwrap_or(0);
let completed = completed_opt.unwrap_or(progress >= target);
let claimed = claimed_opt.unwrap_or(false);
let pkey = period_key_opt.unwrap_or_else(|| if period == "weekly" { weekly_key.clone() } else { daily_key.clone() });
UserQuest {
quest: Quest {
id,
title,
description: desc,
period: period.clone(),
category: cat,
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(if quest_period_is_weekly { weekly_ends.clone() } else { daily_ends.clone() }),
}
},
)
UserQuest {
quest: Quest {
id,
title,
description: desc,
period: period.clone(),
category: cat,
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(if quest_period_is_weekly { weekly_ends.clone() } else { daily_ends.clone() }),
}
})
.collect();
Ok(Json(list))
}
/// Claim a completed quest reward.
pub async fn claim_quest(
auth: AuthUser,
Path(quest_id): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
pub async fn claim_quest(auth: AuthUser, Path(quest_id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
let mut tx = state.db.begin().await?;
let quest: Option<(String, i64, i64)> = sqlx::query_as(
"SELECT period, target_count, xp_reward FROM quests WHERE id = ? AND enabled = 1",
)
.bind(&quest_id)
.fetch_optional(&mut *tx)
.await?;
let quest: Option<(String, i64, i64)> =
sqlx::query_as("SELECT period, target_count, xp_reward FROM quests WHERE id = ? AND enabled = 1")
.bind(&quest_id)
.fetch_optional(&mut *tx)
.await?;
let (period, target_count, xp_reward) =
quest.ok_or_else(|| AppError::bad_request("Quest not found or disabled"))?;
let (period, target_count, xp_reward) = quest.ok_or_else(|| AppError::bad_request("Quest not found or disabled"))?;
let period_key = if period == "weekly" {
current_weekly_key()
} else {
current_daily_key()
};
let period_key = if period == "weekly" { current_weekly_key() } else { current_daily_key() };
let cfg = crate::progression::load(&mut tx).await?;
if !crate::progression::is_assigned(&mut tx, &cfg, &auth.uuid, &period, &period_key, &quest_id).await? {
return Err(AppError::bad_request("That quest isn't one of yours this period"));
}
let user_quest: Option<(i64, bool, bool)> = sqlx::query_as(
"SELECT progress, completed, claimed FROM user_quests WHERE user_uuid = ? AND quest_id = ? AND period_key = ?",
)
.bind(&auth.uuid)
.bind(&quest_id)
.bind(&period_key)
.fetch_optional(&mut *tx)
.await?;
let user_quest: Option<(i64, bool, bool)> =
sqlx::query_as("SELECT progress, completed, claimed FROM user_quests WHERE user_uuid = ? AND quest_id = ? AND period_key = ?")
.bind(&auth.uuid)
.bind(&quest_id)
.bind(&period_key)
.fetch_optional(&mut *tx)
.await?;
let (progress, completed, claimed) = user_quest.unwrap_or((0, false, false));
@@ -283,17 +273,10 @@ pub async fn claim_quest(
.await?;
// Read new total XP & compute new level
let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
.bind(&auth.uuid)
.fetch_one(&mut *tx)
.await?;
let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?").bind(&auth.uuid).fetch_one(&mut *tx).await?;
let (new_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_xp);
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?")
.bind(new_lvl)
.bind(&auth.uuid)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?").bind(new_lvl).bind(&auth.uuid).execute(&mut *tx).await?;
crate::routes::leveling::grant_rewards(&mut tx, &auth.uuid, &now).await?;
crate::rewards::enqueue(&mut tx, &auth.uuid, "quest", &quest_id).await?;
@@ -322,24 +305,8 @@ pub struct AdminQuestRow {
}
/// Admin list all quests.
pub async fn admin_list_quests(
_admin: AdminUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<AdminQuestRow>>> {
let rows: Vec<(
String,
String,
String,
String,
String,
String,
i64,
i64,
String,
bool,
i64,
bool,
)> = sqlx::query_as(
pub async fn admin_list_quests(_admin: AdminUser, State(state): State<AppState>) -> AppResult<Json<Vec<AdminQuestRow>>> {
let rows: Vec<(String, String, String, String, String, String, i64, i64, String, bool, i64, bool)> = sqlx::query_as(
"SELECT q.id, q.title, q.description, q.period, q.category, q.target_stat,
q.target_count, q.xp_reward, q.icon, q.enabled,
COUNT(uq.user_uuid) as total_completions, q.pinned
@@ -353,29 +320,25 @@ pub async fn admin_list_quests(
let list = rows
.into_iter()
.map(
|(id, title, desc, period, cat, stat, target, xp, icon, enabled, completions, pinned)| {
AdminQuestRow {
quest: Quest {
id,
title,
description: desc,
period: period.clone(),
category: cat,
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,
pinned,
}
.map(|(id, title, desc, period, cat, stat, target, xp, icon, enabled, completions, pinned)| AdminQuestRow {
quest: Quest {
id,
title,
description: desc,
period: period.clone(),
category: cat,
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,
pinned,
})
.collect();
Ok(Json(list))
@@ -407,10 +370,7 @@ pub async fn admin_create_quest(
) -> 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_{}_{}", period, uuid::Uuid::new_v4().simple()));
let id = payload.id.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| format!("q_{}_{}", period, uuid::Uuid::new_v4().simple()));
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
@@ -477,15 +437,8 @@ pub async fn admin_update_quest(
}
/// Admin delete quest.
pub async fn admin_delete_quest(
_admin: AdminUser,
Path(id): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
pub async fn admin_delete_quest(_admin: AdminUser, Path(id): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM reward_bundles WHERE source_type = 'quest' AND source_id = ?").bind(&id).execute(&state.db).await?;
sqlx::query("DELETE FROM quests WHERE id = ?")
.bind(&id)
.execute(&state.db)
.await?;
sqlx::query("DELETE FROM quests WHERE id = ?").bind(&id).execute(&state.db).await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+8 -2
View File
@@ -22,7 +22,12 @@ pub struct BundleBody {
actions: Value,
}
pub async fn admin_put(_: AdminUser, State(state): State<AppState>, Path((kind, id)): Path<(String, String)>, Json(body): Json<BundleBody>) -> AppResult<Json<Value>> {
pub async fn admin_put(
_: AdminUser,
State(state): State<AppState>,
Path((kind, id)): Path<(String, String)>,
Json(body): Json<BundleBody>,
) -> AppResult<Json<Value>> {
rewards::check_source(&kind)?;
let actions = rewards::validate(&body.actions)?;
if actions.is_empty() {
@@ -58,7 +63,8 @@ pub async fn admin_deliveries(_: AdminUser, State(state): State<AppState>, Query
/// `{"quest:daily_miner": ["$200", "Diamond x3"], ...}` for the launcher to show next to each reward.
pub async fn public_summaries(_: AuthUser, State(state): State<AppState>) -> AppResult<Json<Value>> {
let rows: Vec<(String, String, String)> = sqlx::query_as("SELECT source_type, source_id, actions FROM reward_bundles").fetch_all(&state.db).await?;
let rows: Vec<(String, String, String)> =
sqlx::query_as("SELECT source_type, source_id, actions FROM reward_bundles").fetch_all(&state.db).await?;
let mut out = BTreeMap::new();
for (kind, id, actions) in rows {
let list: Vec<Value> = serde_json::from_str(&actions).unwrap_or_default();
+144 -142
View File
@@ -266,12 +266,7 @@ pub fn ips_match(sess_str: &str, req_str: &str) -> bool {
}
}
async fn verify_launcher_ip(
state: &AppState,
user_id: Option<i64>,
req_ip: Option<&str>,
since: &str,
) -> AppResult<bool> {
async fn verify_launcher_ip(state: &AppState, user_id: Option<i64>, req_ip: Option<&str>, since: &str) -> AppResult<bool> {
let req_ip = match req_ip {
Some(ip) if !ip.trim().is_empty() => ip.trim(),
_ => return Ok(false),
@@ -422,11 +417,16 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
let leveling_on = s.features.as_ref().and_then(|f| f.leveling_enabled).unwrap_or(true);
let mut xp_before = std::collections::HashMap::new();
if !leveling_on {
for uuid in s.stats.iter().filter_map(|d| dashed(&d.uuid))
.chain(s.events.iter().filter_map(|e| e.uuid.as_deref().and_then(dashed))) {
if xp_before.contains_key(&uuid) { continue; }
for uuid in s.stats.iter().filter_map(|d| dashed(&d.uuid)).chain(s.events.iter().filter_map(|e| e.uuid.as_deref().and_then(dashed)))
{
if xp_before.contains_key(&uuid) {
continue;
}
let xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
.bind(&uuid).fetch_optional(&mut *tx).await?.unwrap_or(0);
.bind(&uuid)
.fetch_optional(&mut *tx)
.await?
.unwrap_or(0);
xp_before.insert(uuid, xp);
}
}
@@ -533,8 +533,8 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
.bind(&kind)
.bind(e.detail.as_deref().map(|d| clip(d, 256)))
.bind(at)
.execute(&mut *tx)
.await?;
.execute(&mut *tx)
.await?;
if quests_on && kind == "action" {
if let (Some(uuid), Some(detail)) = (e.uuid.as_deref().and_then(dashed), e.detail.as_deref()) {
@@ -549,38 +549,49 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
// Check if event unlocks an achievement (if achievements enabled)
if achievements_on {
if let Some(ref euuid) = e.uuid.as_deref().and_then(dashed) {
let event_ach: Vec<String> = sqlx::query_scalar(
"SELECT id FROM achievements WHERE requirement_type = 'event' AND requirement_key = ?",
)
.bind(&kind)
.fetch_all(&mut *tx)
.await?;
let event_ach: Vec<String> =
sqlx::query_scalar("SELECT id FROM achievements WHERE requirement_type = 'event' AND requirement_key = ?")
.bind(&kind)
.fetch_all(&mut *tx)
.await?;
for ach_id in event_ach {
sqlx::query(
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
)
.bind(euuid)
.bind(ach_id)
.bind(&at_now)
.execute(&mut *tx)
.await?;
sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)")
.bind(euuid)
.bind(ach_id)
.bind(&at_now)
.execute(&mut *tx)
.await?;
}
}
}
}
let mut rewarded = std::collections::HashSet::new();
for d in &s.stats { if let Some(uuid) = dashed(&d.uuid) { rewarded.insert(uuid); } }
for e in &s.events { if let Some(uuid) = e.uuid.as_deref().and_then(dashed) { rewarded.insert(uuid); } }
for d in &s.stats {
if let Some(uuid) = dashed(&d.uuid) {
rewarded.insert(uuid);
}
}
for e in &s.events {
if let Some(uuid) = e.uuid.as_deref().and_then(dashed) {
rewarded.insert(uuid);
}
}
if leveling_on {
for uuid in rewarded { crate::routes::leveling::grant_rewards(&mut tx, &uuid, &at_now).await?; }
for uuid in rewarded {
crate::routes::leveling::grant_rewards(&mut tx, &uuid, &at_now).await?;
}
} else {
// Achievement unlocks remain enabled, but their trigger must not grant
// progression on a server whose leveling module is disabled.
for (uuid, xp) in xp_before {
let level = crate::routes::leveling::level_from_xp(xp).0;
sqlx::query("UPDATE user_levels SET global_xp = ?, global_level = ? WHERE uuid = ?")
.bind(xp).bind(level).bind(&uuid).execute(&mut *tx).await?;
.bind(xp)
.bind(level)
.bind(&uuid)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
@@ -633,61 +644,55 @@ async fn update_progression_for_delta(
let delta_xp = (base_xp * global_mult).round() as i64;
let server_delta_xp = (base_xp * server_mult).round() as i64;
if delta_xp > 0 {
sqlx::query(
"INSERT INTO user_levels (uuid, global_xp, global_level, updated_at)
if delta_xp > 0 {
sqlx::query(
"INSERT INTO user_levels (uuid, global_xp, global_level, updated_at)
VALUES (?, ?, 1, ?)
ON CONFLICT (uuid) DO UPDATE SET
global_xp = global_xp + excluded.global_xp,
updated_at = excluded.updated_at",
)
.bind(uuid)
.bind(delta_xp)
.bind(at_now)
.execute(&mut **tx)
.await?;
// Recompute global level
let new_xp: i64 = sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?")
.bind(uuid)
.fetch_one(&mut **tx)
.await?;
let (new_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_xp);
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?")
.bind(new_lvl)
)
.bind(uuid)
.bind(delta_xp)
.bind(at_now)
.execute(&mut **tx)
.await?;
}
if server_delta_xp > 0 {
sqlx::query(
"INSERT INTO server_levels (server_id, uuid, server_xp, server_level, updated_at)
// Recompute global level
let new_xp: i64 =
sqlx::query_scalar("SELECT global_xp FROM user_levels WHERE uuid = ?").bind(uuid).fetch_one(&mut **tx).await?;
let (new_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_xp);
sqlx::query("UPDATE user_levels SET global_level = ? WHERE uuid = ?").bind(new_lvl).bind(uuid).execute(&mut **tx).await?;
}
if server_delta_xp > 0 {
sqlx::query(
"INSERT INTO server_levels (server_id, uuid, server_xp, server_level, updated_at)
VALUES (?, ?, ?, 1, ?)
ON CONFLICT (server_id, uuid) DO UPDATE SET
server_xp = server_xp + excluded.server_xp,
updated_at = excluded.updated_at",
)
.bind(server_id)
.bind(uuid)
.bind(server_delta_xp)
.bind(at_now)
.execute(&mut **tx)
.await?;
// Recompute server level
let new_s_xp: i64 = sqlx::query_scalar("SELECT server_xp FROM server_levels WHERE server_id = ? AND uuid = ?")
.bind(server_id)
.bind(uuid)
.fetch_one(&mut **tx)
.await?;
let (new_s_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_s_xp);
sqlx::query("UPDATE server_levels SET server_level = ? WHERE server_id = ? AND uuid = ?")
.bind(new_s_lvl)
)
.bind(server_id)
.bind(uuid)
.bind(server_delta_xp)
.bind(at_now)
.execute(&mut **tx)
.await?;
// Recompute server level
let new_s_xp: i64 = sqlx::query_scalar("SELECT server_xp FROM server_levels WHERE server_id = ? AND uuid = ?")
.bind(server_id)
.bind(uuid)
.fetch_one(&mut **tx)
.await?;
let (new_s_lvl, _, _, _) = crate::routes::leveling::level_from_xp(new_s_xp);
sqlx::query("UPDATE server_levels SET server_level = ? WHERE server_id = ? AND uuid = ?")
.bind(new_s_lvl)
.bind(server_id)
.bind(uuid)
.execute(&mut **tx)
.await?;
}
}
@@ -715,12 +720,11 @@ async fn update_progression_for_delta(
}
// Daily quests matching this stat
let d_quests: Vec<(String, i64)> = sqlx::query_as(
"SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'daily' AND target_stat = ?",
)
.bind(stat_name)
.fetch_all(&mut **tx)
.await?;
let d_quests: Vec<(String, i64)> =
sqlx::query_as("SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'daily' AND target_stat = ?")
.bind(stat_name)
.fetch_all(&mut **tx)
.await?;
for (qid, target) in d_quests.into_iter().filter(|(id, _)| daily_ids.contains(id)) {
sqlx::query(
@@ -743,12 +747,11 @@ async fn update_progression_for_delta(
}
// Weekly quests matching this stat
let w_quests: Vec<(String, i64)> = sqlx::query_as(
"SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'weekly' AND target_stat = ?",
)
.bind(stat_name)
.fetch_all(&mut **tx)
.await?;
let w_quests: Vec<(String, i64)> =
sqlx::query_as("SELECT id, target_count FROM quests WHERE enabled = 1 AND period = 'weekly' AND target_stat = ?")
.bind(stat_name)
.fetch_all(&mut **tx)
.await?;
for (qid, target) in w_quests.into_iter().filter(|(id, _)| weekly_ids.contains(id)) {
sqlx::query(
@@ -802,42 +805,35 @@ async fn update_progression_for_delta(
.await?;
for ach_id in eligible {
sqlx::query(
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
)
.bind(uuid)
.bind(ach_id)
.bind(at_now)
.execute(&mut **tx)
.await?;
sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)")
.bind(uuid)
.bind(ach_id)
.bind(at_now)
.execute(&mut **tx)
.await?;
}
}
}
}
// Level milestone achievements
let global_level: Option<i64> = sqlx::query_scalar("SELECT global_level FROM user_levels WHERE uuid = ?")
.bind(uuid)
.fetch_optional(&mut **tx)
.await?;
let global_level: Option<i64> =
sqlx::query_scalar("SELECT global_level FROM user_levels WHERE uuid = ?").bind(uuid).fetch_optional(&mut **tx).await?;
if let Some(lvl) = global_level.filter(|_| achievements_on) {
let lvl_ach: Vec<String> = sqlx::query_scalar(
"SELECT id FROM achievements WHERE requirement_type = 'level' AND requirement_value <= ?",
)
.bind(lvl)
.fetch_all(&mut **tx)
.await?;
let lvl_ach: Vec<String> =
sqlx::query_scalar("SELECT id FROM achievements WHERE requirement_type = 'level' AND requirement_value <= ?")
.bind(lvl)
.fetch_all(&mut **tx)
.await?;
for ach_id in lvl_ach {
sqlx::query(
"INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)",
)
.bind(uuid)
.bind(ach_id)
.bind(at_now)
.execute(&mut **tx)
.await?;
sqlx::query("INSERT OR IGNORE INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, ?)")
.bind(uuid)
.bind(ach_id)
.bind(at_now)
.execute(&mut **tx)
.await?;
}
}
@@ -964,12 +960,24 @@ pub async fn regenerate_token(_: AdminUser, State(state): State<AppState>, Path(
pub async fn remove(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
// If this server holds its group's shared balances, hand them to the next server first.
let group: String = sqlx::query_scalar("SELECT economy_group FROM game_servers WHERE id = ?").bind(id).fetch_optional(&state.db).await?.unwrap_or_default();
let group: String = sqlx::query_scalar("SELECT economy_group FROM game_servers WHERE id = ?")
.bind(id)
.fetch_optional(&state.db)
.await?
.unwrap_or_default();
if !group.is_empty() && economy_scope(&state.db, id).await? == id {
let next: Option<i64> = sqlx::query_scalar("SELECT MIN(id) FROM game_servers WHERE id <> ? AND economy_group = ? COLLATE NOCASE").bind(id).bind(&group).fetch_one(&state.db).await?;
let next: Option<i64> = sqlx::query_scalar("SELECT MIN(id) FROM game_servers WHERE id <> ? AND economy_group = ? COLLATE NOCASE")
.bind(id)
.bind(&group)
.fetch_one(&state.db)
.await?;
if let Some(next) = next {
for table in ["server_economy", "guild_wallets", "guild_wallet_transactions"] {
let sql = if table == "guild_wallet_transactions" { format!("UPDATE {table} SET server_id = ? WHERE server_id = ?") } else { format!("UPDATE OR IGNORE {table} SET server_id = ? WHERE server_id = ?") };
let sql = if table == "guild_wallet_transactions" {
format!("UPDATE {table} SET server_id = ? WHERE server_id = ?")
} else {
format!("UPDATE OR IGNORE {table} SET server_id = ? WHERE server_id = ?")
};
sqlx::query(&sql).bind(next).bind(id).execute(&state.db).await?;
}
}
@@ -1133,22 +1141,25 @@ pub struct AggregatedStatRow {
/// Public list of servers with status and player counts.
pub async fn public_servers(State(state): State<AppState>) -> AppResult<Json<Value>> {
let servers: Vec<ServerRow> = sqlx::query_as("SELECT * FROM game_servers ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?;
let list: Vec<Value> = servers.into_iter().map(|s| {
let is_online = s.is_online();
json!({
"id": s.id,
"name": s.name,
"instance_id": s.instance_id,
"map": s.map_enabled,
"online": is_online,
"players_online": if is_online { s.online_count } else { 0 },
"players_max": s.max_players,
"software": s.software,
"mc_version": s.mc_version,
"tps": s.tps,
"last_seen": s.last_seen,
let list: Vec<Value> = servers
.into_iter()
.map(|s| {
let is_online = s.is_online();
json!({
"id": s.id,
"name": s.name,
"instance_id": s.instance_id,
"map": s.map_enabled,
"online": is_online,
"players_online": if is_online { s.online_count } else { 0 },
"players_max": s.max_players,
"software": s.software,
"mc_version": s.mc_version,
"tps": s.tps,
"last_seen": s.last_seen,
})
})
}).collect();
.collect();
Ok(Json(json!({ "servers": list })))
}
@@ -1186,10 +1197,7 @@ pub async fn public_server_leaderboard(
}
/// Public global leaderboard aggregated across all servers.
pub async fn global_leaderboard(
State(state): State<AppState>,
Query(q): Query<LeaderboardQuery>,
) -> AppResult<Json<Value>> {
pub async fn global_leaderboard(State(state): State<AppState>, Query(q): Query<LeaderboardQuery>) -> AppResult<Json<Value>> {
let sort = match q.sort.as_str() {
"joins" | "deaths" | "player_kills" | "mob_kills" | "blocks_broken" | "blocks_placed" | "messages" | "last_seen" => q.sort.as_str(),
_ => "playtime_secs",
@@ -1216,9 +1224,7 @@ pub async fn global_leaderboard(
.await?;
let (players, playtime): (i64, Option<i64>) =
sqlx::query_as("SELECT COUNT(DISTINCT uuid), SUM(playtime_secs) FROM player_stats")
.fetch_one(&state.db)
.await?;
sqlx::query_as("SELECT COUNT(DISTINCT uuid), SUM(playtime_secs) FROM player_stats").fetch_one(&state.db).await?;
Ok(Json(json!({
"leaderboard": leaderboard,
@@ -1228,10 +1234,7 @@ pub async fn global_leaderboard(
}
/// Player's personal stats across servers (authenticated endpoint for launcher).
pub async fn account_player_stats(
State(state): State<AppState>,
AuthUser(user): AuthUser,
) -> AppResult<Json<Value>> {
pub async fn account_player_stats(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppResult<Json<Value>> {
let rows: Vec<StatRowNamed> = sqlx::query_as(
"SELECT s.*, g.name AS server_name FROM player_stats s JOIN game_servers g ON g.id = s.server_id WHERE s.uuid = ? ORDER BY s.last_seen DESC",
)
@@ -1253,17 +1256,16 @@ pub async fn account_player_stats(
MAX(last_seen) as last_seen
FROM player_stats
WHERE uuid = ?
GROUP BY uuid"
GROUP BY uuid",
)
.bind(&user.uuid)
.fetch_optional(&state.db)
.await?;
let events: Vec<EventRow> =
sqlx::query_as("SELECT * FROM server_events WHERE uuid = ? ORDER BY id DESC LIMIT 20")
.bind(&user.uuid)
.fetch_all(&state.db)
.await?;
let events: Vec<EventRow> = sqlx::query_as("SELECT * FROM server_events WHERE uuid = ? ORDER BY id DESC LIMIT 20")
.bind(&user.uuid)
.fetch_all(&state.db)
.await?;
Ok(Json(json!({
"username": user.username,
+108 -141
View File
@@ -13,10 +13,7 @@ use serde_json::Value;
// Friends System
// ---------------------------------------------------------------------------
pub async fn list_friends(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<FriendInfo>>> {
pub async fn list_friends(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<Vec<FriendInfo>>> {
let rows: Vec<(String, String, String, String, bool, Option<String>)> = sqlx::query_as(
"SELECT
(CASE WHEN f.user_uuid = ? THEN f.friend_uuid ELSE f.user_uuid END) as target_uuid,
@@ -48,14 +45,7 @@ pub async fn list_friends(
"pending_incoming".to_string()
};
FriendInfo {
uuid,
username,
status: final_status,
online,
playing_on,
last_seen: None,
}
FriendInfo { uuid, username, status: final_status, online, playing_on, last_seen: None }
})
.collect();
@@ -80,12 +70,8 @@ pub async fn send_friend_request(
.ok_or_else(|| AppError::bad_request("Username is required"))?
.trim();
let target: Option<(String, String)> = sqlx::query_as(
"SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE",
)
.bind(name)
.fetch_optional(&state.db)
.await?;
let target: Option<(String, String)> =
sqlx::query_as("SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE").bind(name).fetch_optional(&state.db).await?;
let (target_uuid, target_name) = target.ok_or_else(|| AppError::not_found("Player not found"))?;
@@ -171,11 +157,7 @@ pub async fn accept_friend_request(
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn remove_friend(
auth: AuthUser,
Path(target_uuid): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
pub async fn remove_friend(auth: AuthUser, Path(target_uuid): Path<String>, State(state): State<AppState>) -> AppResult<Json<Value>> {
sqlx::query(
"DELETE FROM friendships
WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)",
@@ -202,10 +184,7 @@ pub async fn respond_friend_request(
State(state): State<AppState>,
Json(payload): Json<RespondFriendPayload>,
) -> AppResult<Json<Value>> {
let target = payload
.target_uuid
.or(payload.friend_uuid)
.ok_or_else(|| AppError::bad_request("Friend UUID is required"))?;
let target = payload.target_uuid.or(payload.friend_uuid).ok_or_else(|| AppError::bad_request("Friend UUID is required"))?;
if payload.accept {
accept_friend_request(auth, Path(target), State(state)).await
@@ -348,23 +327,8 @@ pub async fn send_direct_message(
// Game Invites
// ---------------------------------------------------------------------------
pub async fn list_my_game_invites(
auth: AuthUser,
State(state): State<AppState>,
) -> AppResult<Json<Vec<GameInvite>>> {
let rows: Vec<(
String,
String,
String,
String,
String,
String,
Option<i64>,
Option<String>,
String,
String,
String,
)> = sqlx::query_as(
pub async fn list_my_game_invites(auth: AuthUser, State(state): State<AppState>) -> AppResult<Json<Vec<GameInvite>>> {
let rows: Vec<(String, String, String, String, String, String, Option<i64>, Option<String>, String, String, String)> = sqlx::query_as(
"SELECT gi.id, gi.sender_uuid, gi.sender_name, gi.recipient_uuid,
gi.instance_id, i.name as instance_name,
gi.server_id, gs.name as server_name,
@@ -382,21 +346,19 @@ pub async fn list_my_game_invites(
let list = rows
.into_iter()
.map(
|(id, suuid, sname, ruuid, iid, iname, sid, sname_opt, status, cat, eat)| GameInvite {
id,
sender_uuid: suuid,
sender_name: sname,
recipient_uuid: ruuid,
instance_id: iid,
instance_name: iname,
server_id: sid,
server_name: sname_opt,
status,
created_at: cat,
expires_at: eat,
},
)
.map(|(id, suuid, sname, ruuid, iid, iname, sid, sname_opt, status, cat, eat)| GameInvite {
id,
sender_uuid: suuid,
sender_name: sname,
recipient_uuid: ruuid,
instance_id: iid,
instance_name: iname,
server_id: sid,
server_name: sname_opt,
status,
created_at: cat,
expires_at: eat,
})
.collect();
Ok(Json(list))
@@ -425,10 +387,8 @@ pub async fn send_game_invite(
if !friends {
return Err(AppError::forbidden("You can only invite friends"));
}
let instance: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM instances WHERE id = ?)")
.bind(&payload.instance_id)
.fetch_one(&state.db)
.await?;
let instance: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM instances WHERE id = ?)").bind(&payload.instance_id).fetch_one(&state.db).await?;
if !instance {
return Err(AppError::not_found("Instance not found"));
}
@@ -465,20 +425,14 @@ pub async fn respond_game_invite(
State(state): State<AppState>,
Json(payload): Json<RespondInvitePayload>,
) -> AppResult<Json<Value>> {
let status = if payload.action == "accept" {
"accepted"
} else {
"declined"
};
let status = if payload.action == "accept" { "accepted" } else { "declined" };
sqlx::query(
"UPDATE game_invites SET status = ? WHERE id = ? AND recipient_uuid = ?",
)
.bind(status)
.bind(&invite_id)
.bind(&auth.uuid)
.execute(&state.db)
.await?;
sqlx::query("UPDATE game_invites SET status = ? WHERE id = ? AND recipient_uuid = ?")
.bind(status)
.bind(&invite_id)
.bind(&auth.uuid)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true, "status": status })))
}
@@ -492,21 +446,16 @@ pub async fn get_player_profile(
Path(uuid): Path<String>,
State(state): State<AppState>,
) -> AppResult<Json<UserProfileView>> {
let user_row: Option<(String, String)> = sqlx::query_as(
"SELECT uuid, username FROM users WHERE uuid = ?",
)
.bind(&uuid)
.fetch_optional(&state.db)
.await?;
let user_row: Option<(String, String)> =
sqlx::query_as("SELECT uuid, username FROM users WHERE uuid = ?").bind(&uuid).fetch_optional(&state.db).await?;
let (puuid, username) = user_row.ok_or_else(|| AppError::not_found("Player profile not found"))?;
let prof_row: Option<(String, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT bio, banner_url, custom_badge, featured_achievement_id FROM user_profiles WHERE uuid = ?",
)
.bind(&puuid)
.fetch_optional(&state.db)
.await?;
let prof_row: Option<(String, Option<String>, Option<String>, Option<String>)> =
sqlx::query_as("SELECT bio, banner_url, custom_badge, featured_achievement_id FROM user_profiles WHERE uuid = ?")
.bind(&puuid)
.fetch_optional(&state.db)
.await?;
let (bio, banner_url, custom_badge, feat_ach_id) = prof_row.unwrap_or((String::new(), None, None, None));
@@ -522,12 +471,8 @@ pub async fn get_player_profile(
};
// Unlocked achievements count
let achievements_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?",
)
.bind(&puuid)
.fetch_one(&state.db)
.await?;
let achievements_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?").bind(&puuid).fetch_one(&state.db).await?;
// Recent posts
let post_rows: Vec<(i64, String, String, String, Option<String>, i64, bool, String)> = sqlx::query_as(
@@ -556,16 +501,22 @@ pub async fn get_player_profile(
.collect();
let achievements = crate::routes::achievements::get_achievements_for_user(&state, &puuid, true).await?;
let friends_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM friendships WHERE status = 'accepted' AND (user_uuid = ? OR friend_uuid = ?)")
.bind(&puuid).bind(&puuid).fetch_one(&state.db).await?;
let created_at: String = sqlx::query_scalar("SELECT created_at FROM users WHERE uuid = ?")
.bind(&puuid).fetch_one(&state.db).await?;
let friends_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM friendships WHERE status = 'accepted' AND (user_uuid = ? OR friend_uuid = ?)")
.bind(&puuid)
.bind(&puuid)
.fetch_one(&state.db)
.await?;
let created_at: String = sqlx::query_scalar("SELECT created_at FROM users WHERE uuid = ?").bind(&puuid).fetch_one(&state.db).await?;
let stats: Option<crate::routes::servers::AggregatedStatRow> = sqlx::query_as(
"SELECT uuid, name, SUM(playtime_secs) playtime_secs, SUM(joins) joins, SUM(deaths) deaths,
SUM(player_kills) player_kills, SUM(mob_kills) mob_kills, SUM(blocks_broken) blocks_broken,
SUM(blocks_placed) blocks_placed, SUM(messages) messages, MIN(first_seen) first_seen, MAX(last_seen) last_seen
FROM player_stats WHERE uuid = ? GROUP BY uuid")
.bind(&puuid).fetch_optional(&state.db).await?;
FROM player_stats WHERE uuid = ? GROUP BY uuid",
)
.bind(&puuid)
.fetch_optional(&state.db)
.await?;
let extras = profile_extras(&state, &puuid, viewer.as_ref().map(|v| v.uuid.as_str())).await?;
Ok(Json(UserProfileView {
online: extras.online,
@@ -620,7 +571,8 @@ struct ProfileExtras {
async fn profile_extras(state: &AppState, uuid: &str, viewer: Option<&str>) -> AppResult<ProfileExtras> {
let db = &state.db;
let online: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM server_online WHERE uuid = ?)").bind(uuid).fetch_one(db).await?;
let last_seen: Option<String> = sqlx::query_scalar("SELECT MAX(last_seen) FROM player_stats WHERE uuid = ?").bind(uuid).fetch_one(db).await?;
let last_seen: Option<String> =
sqlx::query_scalar("SELECT MAX(last_seen) FROM player_stats WHERE uuid = ?").bind(uuid).fetch_one(db).await?;
let guild: Option<(String, String, String, String)> = sqlx::query_as(
"SELECT g.id, g.name, g.tag, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id WHERE gm.uuid = ? ORDER BY gm.joined_at LIMIT 1",
@@ -675,12 +627,21 @@ async fn profile_extras(state: &AppState, uuid: &str, viewer: Option<&str>) -> A
let text = match kind.as_str() {
"join" => format!("Joined {server}"),
"death" => format!("Died on {server}"),
"kill" => format!("Defeated {}", if detail.is_empty() { "a foe".into() } else { detail.chars().take(40).collect::<String>() }),
_ => format!("Earned {}", if detail.is_empty() { "an advancement".into() } else { detail.chars().take(60).collect::<String>() }),
"kill" => {
format!("Defeated {}", if detail.is_empty() { "a foe".into() } else { detail.chars().take(40).collect::<String>() })
}
_ => format!(
"Earned {}",
if detail.is_empty() { "an advancement".into() } else { detail.chars().take(60).collect::<String>() }
),
};
scopenet_shared::ProfileActivity { kind, text, at }
})
.chain(unlocked.into_iter().map(|(title, at)| scopenet_shared::ProfileActivity { kind: "achievement".into(), text: format!("Unlocked {title}"), at }))
.chain(unlocked.into_iter().map(|(title, at)| scopenet_shared::ProfileActivity {
kind: "achievement".into(),
text: format!("Unlocked {title}"),
at,
}))
.collect();
recent_activity.sort_by(|a, b| b.at.cmp(&a.at));
recent_activity.truncate(8);
@@ -715,7 +676,8 @@ async fn profile_extras(state: &AppState, uuid: &str, viewer: Option<&str>) -> A
None => ("none".to_string(), vec![]),
};
let accent_color: Option<String> = sqlx::query_scalar("SELECT accent_color FROM user_profiles WHERE uuid = ?").bind(uuid).fetch_optional(db).await?.flatten();
let accent_color: Option<String> =
sqlx::query_scalar("SELECT accent_color FROM user_profiles WHERE uuid = ?").bind(uuid).fetch_optional(db).await?.flatten();
let discord_linked: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM account_connections ac JOIN users u ON u.id = ac.user_id WHERE u.uuid = ? AND ac.provider = 'discord')")
.bind(uuid)
.fetch_one(db)
@@ -725,7 +687,11 @@ async fn profile_extras(state: &AppState, uuid: &str, viewer: Option<&str>) -> A
online,
last_seen,
guild: guild.map(|(id, name, tag, role)| scopenet_shared::ProfileGuild { id, name, tag, role }),
rank: rank.map(|(primary, display, prefix, server_name)| scopenet_shared::ProfileRank { display: if display.is_empty() { primary } else { display }, prefix, server_name }),
rank: rank.map(|(primary, display, prefix, server_name)| scopenet_shared::ProfileRank {
display: if display.is_empty() { primary } else { display },
prefix,
server_name,
}),
favorite_server,
wealth,
recent_activity,
@@ -782,7 +748,11 @@ pub async fn update_my_profile(
if !valid {
return Err(AppError::bad_request("accent colour must look like #8b6cff"));
}
sqlx::query("UPDATE user_profiles SET accent_color = ? WHERE uuid = ?").bind(if color.is_empty() { None } else { Some(color) }).bind(&auth.uuid).execute(&state.db).await?;
sqlx::query("UPDATE user_profiles SET accent_color = ? WHERE uuid = ?")
.bind(if color.is_empty() { None } else { Some(color) })
.bind(&auth.uuid)
.execute(&state.db)
.await?;
}
Ok(Json(serde_json::json!({ "ok": true })))
@@ -835,42 +805,27 @@ pub async fn create_user_post(
}))
}
pub async fn delete_user_post(
auth: AuthUser,
Path(post_id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM user_posts WHERE id = ? AND user_uuid = ?")
.bind(post_id)
.bind(&auth.uuid)
.execute(&state.db)
.await?;
pub async fn delete_user_post(auth: AuthUser, Path(post_id): Path<i64>, State(state): State<AppState>) -> AppResult<Json<Value>> {
sqlx::query("DELETE FROM user_posts WHERE id = ? AND user_uuid = ?").bind(post_id).bind(&auth.uuid).execute(&state.db).await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn like_user_post(
auth: AuthUser,
Path(post_id): Path<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
pub async fn like_user_post(auth: AuthUser, Path(post_id): Path<i64>, State(state): State<AppState>) -> AppResult<Json<Value>> {
let mut tx = state.db.begin().await?;
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_posts WHERE id = ?)")
.bind(post_id)
.fetch_one(&mut *tx)
.await?;
if !exists { return Err(AppError::not_found("Post not found")); }
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_posts WHERE id = ?)").bind(post_id).fetch_one(&mut *tx).await?;
if !exists {
return Err(AppError::not_found("Post not found"));
}
let added = sqlx::query("INSERT OR IGNORE INTO user_post_likes (post_id, user_uuid) VALUES (?, ?)")
.bind(post_id)
.bind(&auth.uuid)
.execute(&mut *tx)
.await?
.rows_affected() > 0;
.rows_affected()
> 0;
if added {
sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?")
.bind(post_id)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?").bind(post_id).execute(&mut *tx).await?;
}
tx.commit().await?;
@@ -895,9 +850,21 @@ pub async fn search_members(
let q = query.q.unwrap_or_default().trim().to_lowercase().replace(['%', '_', '\\'], "");
let pattern = format!("%{q}%");
let rows: Vec<(String, String, String, String, Option<String>, Option<i64>, Option<String>, i64, Option<String>, bool, Option<String>, Option<String>)> =
sqlx::query_as(
"SELECT u.uuid, u.username, u.role, u.status, u.skin_hash,
let rows: Vec<(
String,
String,
String,
String,
Option<String>,
Option<i64>,
Option<String>,
i64,
Option<String>,
bool,
Option<String>,
Option<String>,
)> = sqlx::query_as(
"SELECT u.uuid, u.username, u.role, u.status, u.skin_hash,
ul.global_level, ul.title,
COALESCE((SELECT SUM(ps.playtime_secs) FROM player_stats ps WHERE ps.uuid = u.uuid), 0),
(SELECT MAX(ps.last_seen) FROM player_stats ps WHERE ps.uuid = u.uuid),
@@ -909,12 +876,12 @@ pub async fn search_members(
WHERE u.status = 'active' AND u.uuid <> ?1 AND (?2 = '' OR LOWER(u.username) LIKE ?3)
ORDER BY (CASE WHEN LOWER(u.username) = ?2 THEN 0 ELSE 1 END), 10 DESC, u.username COLLATE NOCASE
LIMIT 60",
)
.bind(&auth.uuid)
.bind(&q)
.bind(&pattern)
.fetch_all(&state.db)
.await?;
)
.bind(&auth.uuid)
.bind(&q)
.bind(&pattern)
.fetch_all(&state.db)
.await?;
let list = rows
.into_iter()
+31 -7
View File
@@ -42,13 +42,20 @@ pub struct DimQuery {
dim: String,
}
pub async fn game_tiles(GameServer(server): GameServer, State(state): State<AppState>, Query(q): Query<DimQuery>, body: Bytes) -> AppResult<Json<Value>> {
pub async fn game_tiles(
GameServer(server): GameServer,
State(state): State<AppState>,
Query(q): Query<DimQuery>,
body: Bytes,
) -> AppResult<Json<Value>> {
require_enabled(server.map_enabled)?;
let slug = dim_slug(&q.dim).ok_or_else(|| AppError::bad_request("unknown dimension"))?;
let tiles = parse_tiles(&body).map_err(AppError::bad_request)?;
let map = state.worldmap.clone();
let id = server.id;
let stored = tokio::task::spawn_blocking(move || map.store_tiles(id, &slug, &tiles)).await.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
let stored = tokio::task::spawn_blocking(move || map.store_tiles(id, &slug, &tiles))
.await
.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
Ok(Json(json!({ "stored": stored })))
}
@@ -57,14 +64,22 @@ pub struct PlayersBody {
players: Vec<LivePlayer>,
}
pub async fn game_players(GameServer(server): GameServer, State(state): State<AppState>, Json(b): Json<PlayersBody>) -> AppResult<Json<Value>> {
pub async fn game_players(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(b): Json<PlayersBody>,
) -> AppResult<Json<Value>> {
require_enabled(server.map_enabled)?;
state.worldmap.set_players(server.id, b.players);
Ok(Json(json!({ "ok": true })))
}
/// Claims and pins, already drawn into shapes by the game server. Stored as is, size-limited.
pub async fn game_overlay(GameServer(server): GameServer, State(state): State<AppState>, Json(overlay): Json<Value>) -> AppResult<Json<Value>> {
pub async fn game_overlay(
GameServer(server): GameServer,
State(state): State<AppState>,
Json(overlay): Json<Value>,
) -> AppResult<Json<Value>> {
require_enabled(server.map_enabled)?;
let ok = overlay.is_object() && overlay.get("claims").is_none_or(|c| c.is_array()) && overlay.get("pins").is_none_or(|p| p.is_array());
if !ok || overlay.to_string().len() > 4 * 1024 * 1024 {
@@ -134,7 +149,9 @@ pub async fn admin_status(_: AdminUser, State(state): State<AppState>, Path(id):
pub async fn admin_reset(_: AdminUser, State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
get_server(&state, id).await?;
let map = state.worldmap.clone();
tokio::task::spawn_blocking(move || map.reset(id)).await.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
tokio::task::spawn_blocking(move || map.reset(id))
.await
.map_err(|e| AppError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))??;
Ok(Json(json!({ "ok": true })))
}
@@ -146,7 +163,12 @@ pub struct TokenQuery {
/// `GET /api/map/{server}/{dimension}/{zoom}/{x}/{y}.png?t=<token>`. The token comes from [`viewer_info`], so only signed-in
/// players can load tiles, yet plain `<img>` tags work.
pub async fn tile(State(state): State<AppState>, Path((id, dim, z, x, file)): Path<(i64, String, u8, i32, String)>, Query(q): Query<TokenQuery>, headers: axum::http::HeaderMap) -> Response {
pub async fn tile(
State(state): State<AppState>,
Path((id, dim, z, x, file)): Path<(i64, String, u8, i32, String)>,
Query(q): Query<TokenQuery>,
headers: axum::http::HeaderMap,
) -> Response {
let not_found = || (StatusCode::NOT_FOUND, "no tile").into_response();
if !state.worldmap.token_ok(id, &q.t) {
return (StatusCode::UNAUTHORIZED, "map link expired").into_response();
@@ -193,7 +215,9 @@ async fn landing_map(state: &AppState, requested: i64) -> AppResult<(i64, Value)
if !server.map_enabled {
return Err(AppError::not_found("no public map"));
}
let flag = |key: &str, default: bool| block.options.get(key).and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true"))).unwrap_or(default);
let flag = |key: &str, default: bool| {
block.options.get(key).and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true"))).unwrap_or(default)
};
Ok((id, json!({ "claims": flag("show_claims", true), "pins": flag("show_pins", true), "players": flag("show_players", false) })))
}
+940 -88
View File
File diff suppressed because it is too large. Load diff
+19 -4
View File
@@ -67,7 +67,10 @@ pub fn dim_slug(id: &str) -> Option<String> {
"end" => "the_end".to_string(),
other => other.replace(':', "__"),
};
let ok = !slug.is_empty() && slug.len() <= 48 && slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '.')) && !slug.contains("..");
let ok = !slug.is_empty()
&& slug.len() <= 48
&& slug.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '.'))
&& !slug.contains("..");
ok.then_some(slug)
}
@@ -175,7 +178,8 @@ impl WorldMap {
pub fn summary(&self, id: i64) -> Value {
let mut dims = Vec::new();
let Ok(rd) = std::fs::read_dir(self.server_dir(id)) else { return json!({ "dimensions": dims }) };
let mut names: Vec<String> = rd.flatten().filter(|e| e.path().is_dir()).filter_map(|e| e.file_name().to_str().map(String::from)).collect();
let mut names: Vec<String> =
rd.flatten().filter(|e| e.path().is_dir()).filter_map(|e| e.file_name().to_str().map(String::from)).collect();
names.sort_by_key(|n| (n != "overworld", n != "the_nether", n != "the_end", n.clone()));
for slug in names {
let (mut count, mut bytes) = (0u64, 0u64);
@@ -274,7 +278,10 @@ impl WorldMap {
if let Some(v) = self.overlays.lock().unwrap().get(&id) {
return v.clone();
}
let loaded = std::fs::read_to_string(self.server_dir(id).join("overlay.json")).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or_else(|| json!({ "claims": [], "pins": [] }));
let loaded = std::fs::read_to_string(self.server_dir(id).join("overlay.json"))
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.unwrap_or_else(|| json!({ "claims": [], "pins": [] }));
self.overlays.lock().unwrap().insert(id, loaded.clone());
loaded
}
@@ -379,7 +386,15 @@ mod tests {
assert!(!map.token_ok(4, &t), "a token only opens its own server");
assert!(!map.token_ok(3, "nope"));
let p = |name: &str, x: f64| LivePlayer { uuid: "ab-12\u{7}zz".into(), name: format!("{name}\n"), dimension: "minecraft:overworld".into(), x, y: 64.0, z: 0.0, yaw: 0.0 };
let p = |name: &str, x: f64| LivePlayer {
uuid: "ab-12\u{7}zz".into(),
name: format!("{name}\n"),
dimension: "minecraft:overworld".into(),
x,
y: 64.0,
z: 0.0,
yaw: 0.0,
};
map.set_players(3, vec![p("Alex", 1.0), p("Bad", f64::NAN), p("Far", 1e9)]);
let live = map.live_players(3);
assert_eq!(live.len(), 1);
+17 -3
View File
@@ -32,13 +32,22 @@ async fn claim_index_follows_claims_and_membership() {
assert!(same.get("claims").is_none());
// A guild, then a claim: the revision moves and the index lists the chunk.
let (_, g) = t.call("POST", "/api/v1/guilds", Some(&tokens[0]), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
let (_, g) =
t.call("POST", "/api/v1/guilds", Some(&tokens[0]), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
let gid = g["id"].as_str().unwrap().to_string();
let (_, r1) = index(Some(rev0.clone())).await;
assert_eq!(r1["unchanged"], false, "guild creation bumps the revision");
let claim = |x: i32| {
let (t, tok, gid) = (&t, tokens[0].clone(), gid.clone());
async move { t.call("POST", &format!("/api/v1/guilds/{gid}/claim"), Some(&tok), Some(json!({"server_id": sid, "dimension": "minecraft:overworld", "chunk_x": x, "chunk_z": -4}))).await }
async move {
t.call(
"POST",
&format!("/api/v1/guilds/{gid}/claim"),
Some(&tok),
Some(json!({"server_id": sid, "dimension": "minecraft:overworld", "chunk_x": x, "chunk_z": -4})),
)
.await
}
};
let (s, c1) = claim(3).await;
assert_eq!(s, StatusCode::OK, "{c1}");
@@ -53,7 +62,12 @@ async fn claim_index_follows_claims_and_membership() {
assert_eq!(index(Some(rev2.clone())).await.1["unchanged"], true);
// Membership changes bump it too.
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Steve', 'member', 'now')").bind(&gid).bind(&steve_uuid).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Steve', 'member', 'now')")
.bind(&gid)
.bind(&steve_uuid)
.execute(&t.db)
.await
.unwrap();
let (_, idx) = index(Some(rev2.clone())).await;
assert_eq!(idx["unchanged"], false);
assert_eq!(idx["members"][&gid].as_array().unwrap().len(), 2);
+29 -8
View File
@@ -29,12 +29,21 @@ async fn plugins_read_players_and_change_progress() {
assert_eq!(call("player/info", json!({"uuid": "nope"})).await.0, StatusCode::BAD_REQUEST);
// Info: levels, balance, guild, quests.
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 321.5, 'now')").bind(sid).bind(&steve).execute(&t.db).await.unwrap();
let (_, g) = t.call("POST", "/api/v1/guilds", Some(&tokens[0]), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 321.5, 'now')")
.bind(sid)
.bind(&steve)
.execute(&t.db)
.await
.unwrap();
let (_, g) =
t.call("POST", "/api/v1/guilds", Some(&tokens[0]), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
t.call("GET", "/api/v1/quests/my", Some(&tokens[0]), None).await;
let (s, info) = call("player/info", json!({"uuid": steve})).await;
assert_eq!(s, StatusCode::OK, "{info}");
assert_eq!((info["name"].as_str(), info["balance"].as_f64(), info["guild"]["tag"].as_str(), info["guild"]["role"].as_str()), (Some("Steve"), Some(321.5), Some("IRON"), Some("leader")));
assert_eq!(
(info["name"].as_str(), info["balance"].as_f64(), info["guild"]["tag"].as_str(), info["guild"]["role"].as_str()),
(Some("Steve"), Some(321.5), Some("IRON"), Some("leader"))
);
// Founding a guild unlocks an achievement worth XP, so the player starts above zero.
let base = info["global"]["xp"].as_i64().unwrap();
assert!(base > 0);
@@ -53,21 +62,33 @@ async fn plugins_read_players_and_change_progress() {
let (_, r) = call("player/xp", xp("x3", "global", -200)).await;
assert_eq!(r["xp"].as_i64(), Some(base + 1000));
assert_eq!(call("player/xp", xp("x4", "global", 0)).await.0, StatusCode::BAD_REQUEST);
assert_eq!(call("player/xp", json!({"operation_id": "x5", "uuid": "00000000-0000-0000-0000-00000000dead", "amount": 5})).await.0, StatusCode::NOT_FOUND);
assert_eq!(
call("player/xp", json!({"operation_id": "x5", "uuid": "00000000-0000-0000-0000-00000000dead", "amount": 5})).await.0,
StatusCode::NOT_FOUND
);
let (_, info) = call("player/info", json!({"uuid": steve})).await;
assert_eq!((info["global"]["xp"].as_i64(), info["server"]["xp"].as_i64(), info["server"]["level"].as_i64()), (Some(base + 1000), Some(400), Some(2)));
assert_eq!(
(info["global"]["xp"].as_i64(), info["server"]["xp"].as_i64(), info["server"]["level"].as_i64()),
(Some(base + 1000), Some(400), Some(2))
);
// Quest objectives by quest id (complete it), and by action.
let (_, quests) = t.call("GET", "/api/v1/quests/my", Some(&tokens[0]), None).await;
let quest = quests.as_array().unwrap().iter().find(|q| q["quest"]["period"] == "daily").unwrap()["quest"]["id"].as_str().unwrap().to_string();
let quest =
quests.as_array().unwrap().iter().find(|q| q["quest"]["period"] == "daily").unwrap()["quest"]["id"].as_str().unwrap().to_string();
let (s, r) = call("player/quest-objective", json!({"operation_id": "q1", "uuid": steve, "objective": quest, "amount": 0})).await;
assert_eq!((s, r["advanced"].clone()), (StatusCode::OK, json!(true)), "{r}");
let (_, quests) = t.call("GET", "/api/v1/quests/my", Some(&tokens[0]), None).await;
let done = quests.as_array().unwrap().iter().find(|q| q["quest"]["id"] == quest.as_str()).unwrap();
assert_eq!(done["completed"], true);
let (_, r) = call("player/quest-objective", json!({"operation_id": "q2", "uuid": steve, "objective": "block_broken:DIAMOND_ORE@minecraft:overworld", "amount": 3})).await;
let (_, r) = call(
"player/quest-objective",
json!({"operation_id": "q2", "uuid": steve, "objective": "block_broken:DIAMOND_ORE@minecraft:overworld", "amount": 3}),
)
.await;
assert_eq!(r["advanced"], true);
let (_, r) = call("player/quest-objective", json!({"operation_id": "q3", "uuid": steve, "objective": "nothing:at_all", "amount": 3})).await;
let (_, r) =
call("player/quest-objective", json!({"operation_id": "q3", "uuid": steve, "objective": "nothing:at_all", "amount": 3})).await;
assert_eq!(r["advanced"], false);
let (_, info) = call("player/info", json!({"uuid": steve})).await;
assert_eq!(info["quests"]["daily"]["completed"], 1);
+13 -3
View File
@@ -20,7 +20,9 @@ async fn discord_messages_are_customised_and_previewed_with_real_data() {
tpl["achievement"]["image"] = json!("https://cdn.example.com/banner.png");
let (s, _) = t.call("PUT", "/api/admin/discord/studio/templates", Some(&admin), Some(json!({"templates": tpl.clone()}))).await;
assert_eq!(s, StatusCode::OK);
let (s, p) = t.call("POST", "/api/admin/discord/studio/preview", Some(&admin), Some(json!({"kind": "achievement", "style": tpl["achievement"]}))).await;
let (s, p) = t
.call("POST", "/api/admin/discord/studio/preview", Some(&admin), Some(json!({"kind": "achievement", "style": tpl["achievement"]})))
.await;
assert_eq!(s, StatusCode::OK, "{p}");
let e = &p["embeds"][0];
assert_eq!(e["title"], "🎉 admin did it!");
@@ -34,7 +36,12 @@ async fn discord_messages_are_customised_and_previewed_with_real_data() {
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Survival SMP", "instance_id": "smp"}))).await;
let sid = srv["server"]["id"].as_i64().unwrap();
t.call("POST", "/api/v1/guilds", Some(&steve_token), Some(json!({"instance_id": "smp", "name": "Iron Wolves", "tag": "IRON"}))).await;
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 123456, 'now')").bind(sid).bind(t.uuid("Steve").await).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 123456, 'now')")
.bind(sid)
.bind(t.uuid("Steve").await)
.execute(&t.db)
.await
.unwrap();
for (kind, needle) in [("status", "Survival SMP"), ("guilds", "Iron Wolves"), ("baltop", "$123,456"), ("leaderboard", "Leaderboard")] {
let cfg = all["live"][kind].clone();
let (s, p) = t.call("POST", "/api/admin/discord/studio/preview", Some(&admin), Some(json!({"kind": kind, "live": cfg}))).await;
@@ -47,7 +54,10 @@ async fn discord_messages_are_customised_and_previewed_with_real_data() {
live["status"]["interval_secs"] = json!(5);
live["status"]["limit"] = json!(500);
live["status"]["webhook_url"] = json!("https://evil.example.com/hook");
assert_eq!(t.call("PUT", "/api/admin/discord/studio/live", Some(&admin), Some(json!({"live": live.clone()}))).await.0, StatusCode::BAD_REQUEST);
assert_eq!(
t.call("PUT", "/api/admin/discord/studio/live", Some(&admin), Some(json!({"live": live.clone()}))).await.0,
StatusCode::BAD_REQUEST
);
live["status"]["webhook_url"] = json!("https://discord.com/api/webhooks/1/abc");
assert_eq!(t.call("PUT", "/api/admin/discord/studio/live", Some(&admin), Some(json!({"live": live}))).await.0, StatusCode::OK);
let (_, again) = t.call("GET", "/api/admin/discord/studio", Some(&admin), None).await;
+23 -6
View File
@@ -20,21 +20,32 @@ async fn emails_use_templates_audiences_and_opt_outs() {
assert_eq!(t.call("PUT", "/api/admin/email/templates", Some(&admin), Some(ok)).await.0, StatusCode::OK);
// Placeholders fill per player, and the HTML is branded and has an unsubscribe link.
let (s, p) = t.call("POST", "/api/admin/email/preview", Some(&admin), Some(json!({"subject": "Hi {player}", "body": "Hello **{player}**\n\n[button: Go]({panel_url})"}))).await;
let (s, p) = t
.call(
"POST",
"/api/admin/email/preview",
Some(&admin),
Some(json!({"subject": "Hi {player}", "body": "Hello **{player}**\n\n[button: Go]({panel_url})"})),
)
.await;
assert_eq!(s, StatusCode::OK, "{p}");
assert_eq!(p["subject"], "Hi admin");
assert!(p["html"].as_str().unwrap().contains("<strong>admin</strong>") && p["html"].as_str().unwrap().contains("Unsubscribe"));
assert!(p["text"].as_str().unwrap().contains("/api/v1/email/unsubscribe?u="));
// Only active players with an email count.
let count = |a: Value| { let (t, admin) = (&t, admin.clone()); async move { t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": a}))).await.1 } };
let count = |a: Value| {
let (t, admin) = (&t, admin.clone());
async move { t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": a}))).await.1 }
};
assert_eq!(count(json!({"kind": "all"})).await["count"], 2, "Steve and Alex have an address; Notch and admin don't");
assert_eq!(count(json!({"kind": "players", "names": ["steve", "nobody"]})).await["count"], 1);
// Opting out through the link removes a player from marketing mail, but not from account notices.
let steve = t.uuid("Steve").await;
let token = {
let secret: String = sqlx::query_scalar("SELECT value FROM kv WHERE key = 'email_unsubscribe_secret'").fetch_one(&t.db).await.unwrap();
let secret: String =
sqlx::query_scalar("SELECT value FROM kv WHERE key = 'email_unsubscribe_secret'").fetch_one(&t.db).await.unwrap();
let secret = secret.trim_matches('"').to_string();
use sha2::{Digest, Sha256};
Sha256::digest(format!("{secret}:{steve}").as_bytes()).iter().take(16).map(|b| format!("{b:02x}")).collect::<String>()
@@ -44,15 +55,21 @@ async fn emails_use_templates_audiences_and_opt_outs() {
assert_eq!(count(json!({"kind": "all"})).await["count"], 2, "a wrong token changes nothing");
t.fetch(&format!("/api/v1/email/unsubscribe?u={steve}&t={token}")).await;
assert_eq!(count(json!({"kind": "all"})).await["count"], 1);
let (_, important) = t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": {"kind": "all"}, "important": true}))).await;
let (_, important) =
t.call("POST", "/api/admin/email/audience", Some(&admin), Some(json!({"audience": {"kind": "all"}, "important": true}))).await;
assert_eq!(important["count"], 2);
// Without SMTP configured, a send is queued but each message fails with a clear reason in the log.
let (s, sent) = t.call("POST", "/api/admin/email/send", Some(&admin), Some(json!({"subject": "Hi", "body": "Hello", "audience": {"kind": "all"}}))).await;
let (s, sent) = t
.call("POST", "/api/admin/email/send", Some(&admin), Some(json!({"subject": "Hi", "body": "Hello", "audience": {"kind": "all"}})))
.await;
assert_eq!(s, StatusCode::OK, "{sent}");
for _ in 0..40 {
let (_, all) = t.call("GET", "/api/admin/email", Some(&admin), None).await;
if all["log"][0]["failed"] == 1 { assert!(all["log"][0]["last_error"].as_str().unwrap().contains("Resend")); return; }
if all["log"][0]["failed"] == 1 {
assert!(all["log"][0]["last_error"].as_str().unwrap().contains("Resend"));
return;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
panic!("the send was never logged");
+30 -23
View File
@@ -38,7 +38,8 @@ async fn leveling_and_quests_progression() {
let (alex_token, _, alex_uuid, _) = setup_users(&t).await;
let (_server_id, server_token) = create_test_server(&t, &admin).await;
// Hand every quest to every player so progress can be checked per quest.
let (s, v) = t.call("PUT", "/api/admin/progression", Some(&admin), Some(json!({"daily_quest_limit": 0, "weekly_quest_limit": 0}))).await;
let (s, v) =
t.call("PUT", "/api/admin/progression", Some(&admin), Some(json!({"daily_quest_limit": 0, "weekly_quest_limit": 0}))).await;
assert_eq!(s, StatusCode::OK, "{v}");
// 1. Initial level check
@@ -97,8 +98,8 @@ async fn leveling_and_quests_progression() {
assert!(current_xp > 0, "Alex should have earned XP");
let (s, quests_after) = t.call("GET", "/api/v1/quests/me", Some(&alex_token), None).await;
assert_eq!(s, StatusCode::OK);
let progress = |id: &str| quests_after.as_array().unwrap().iter()
.find(|q| q["quest"]["id"] == id).unwrap()["progress"].as_i64().unwrap();
let progress =
|id: &str| quests_after.as_array().unwrap().iter().find(|q| q["quest"]["id"] == id).unwrap()["progress"].as_i64().unwrap();
assert_eq!(progress("q_d_mine_diamond"), 4);
assert_eq!(progress("q_d_mine_coal"), 0);
@@ -146,8 +147,14 @@ async fn guilds_and_land_claims() {
let admin = t.login("admin", "supersecret").await;
let (alex_token, _, alex_uuid, steve_uuid) = setup_users(&t).await;
let (server_id, server_token) = create_test_server(&t, &admin).await;
let (status, updated) = t.call("PUT", &format!("/api/admin/servers/{server_id}"), Some(&admin),
Some(json!({"name": "Survival Hub", "instance_id": "smp-pack"}))).await;
let (status, updated) = t
.call(
"PUT",
&format!("/api/admin/servers/{server_id}"),
Some(&admin),
Some(json!({"name": "Survival Hub", "instance_id": "smp-pack"})),
)
.await;
assert_eq!(status, StatusCode::OK, "{updated}");
// 1. Alex founds a guild
@@ -261,11 +268,23 @@ async fn guilds_and_land_claims() {
assert_eq!(check_unclaimed["allowed"], true);
// The in-game command uses the server unclaim route rather than DELETE.
let (s, _) = t.call("POST", "/api/server/v1/guilds/claim", Some(&server_token),
Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7}))).await;
let (s, _) = t
.call(
"POST",
"/api/server/v1/guilds/claim",
Some(&server_token),
Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7})),
)
.await;
assert_eq!(s, StatusCode::OK);
let (s, response) = t.call("POST", "/api/server/v1/guilds/unclaim", Some(&server_token),
Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7}))).await;
let (s, response) = t
.call(
"POST",
"/api/server/v1/guilds/unclaim",
Some(&server_token),
Some(json!({"uuid": &alex_uuid, "dimension": "minecraft:overworld", "chunk_x": 3, "chunk_z": 7})),
)
.await;
assert_eq!(s, StatusCode::OK, "{response}");
}
@@ -275,24 +294,12 @@ async fn social_friends_dms_and_profiles() {
let (alex_token, steve_token, alex_uuid, steve_uuid) = setup_users(&t).await;
// 1. Alex sends friend request to Steve
let (s, _) = t
.call(
"POST",
"/api/v1/social/friends/request",
Some(&alex_token),
Some(json!({"friend_username": "Steve"})),
)
.await;
let (s, _) = t.call("POST", "/api/v1/social/friends/request", Some(&alex_token), Some(json!({"friend_username": "Steve"}))).await;
assert_eq!(s, StatusCode::OK);
// 2. Steve accepts friend request
let (s, _) = t
.call(
"POST",
"/api/v1/social/friends/respond",
Some(&steve_token),
Some(json!({"friend_uuid": &alex_uuid, "accept": true})),
)
.call("POST", "/api/v1/social/friends/respond", Some(&steve_token), Some(json!({"friend_uuid": &alex_uuid, "accept": true})))
.await;
assert_eq!(s, StatusCode::OK);
+35 -9
View File
@@ -21,14 +21,20 @@ async fn fixture() -> Fixture {
for n in ["Alex", "Steve", "Mia"] {
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123"}))).await;
}
let (alex, steve, mia) = (t.login("Alex", "password123").await, t.login("Steve", "password123").await, t.login("Mia", "password123").await);
let (alex, steve, mia) =
(t.login("Alex", "password123").await, t.login("Steve", "password123").await, t.login("Mia", "password123").await);
let (alex_uuid, steve_uuid) = (t.uuid("Alex").await, t.uuid("Steve").await);
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await;
let sid = srv["server"]["id"].as_i64().unwrap();
let server = srv["token"].as_str().unwrap().to_string();
let (_, g) = t.call("POST", "/api/v1/guilds", Some(&alex), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
let gid = g["id"].as_str().unwrap().to_string();
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Steve', 'member', '2026-01-01')").bind(&gid).bind(&steve_uuid).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Steve', 'member', '2026-01-01')")
.bind(&gid)
.bind(&steve_uuid)
.execute(&t.db)
.await
.unwrap();
// Mia leads a second guild.
t.call("POST", "/api/v1/guilds", Some(&mia), Some(json!({"instance_id": "smp", "name": "Void", "tag": "VOID"}))).await;
Fixture { t, server, sid, alex, steve, alex_uuid, steve_uuid, gid }
@@ -39,7 +45,12 @@ impl Fixture {
self.t.call("POST", &format!("/api/server/v1/{path}"), Some(&self.server), Some(body)).await
}
async fn guild_balance(&self) -> f64 {
sqlx::query_scalar("SELECT COALESCE((SELECT balance FROM guild_wallets WHERE guild_id = ? AND server_id = ?), 0)").bind(&self.gid).bind(self.sid).fetch_one(&self.t.db).await.unwrap()
sqlx::query_scalar("SELECT COALESCE((SELECT balance FROM guild_wallets WHERE guild_id = ? AND server_id = ?), 0)")
.bind(&self.gid)
.bind(self.sid)
.fetch_one(&self.t.db)
.await
.unwrap()
}
}
@@ -87,12 +98,18 @@ async fn bank_deposits_withdrawals_and_roles() {
#[tokio::test]
async fn shop_sales_and_guild_payments() {
let f = fixture().await;
let credit = json!({"operation_id": "sale-1", "uuid": f.steve_uuid, "username": "Steve", "amount": 64.0, "description": "64x Cobblestone"});
let credit =
json!({"operation_id": "sale-1", "uuid": f.steve_uuid, "username": "Steve", "amount": 64.0, "description": "64x Cobblestone"});
let (s, r) = f.post("guilds/bank/credit", credit.clone()).await;
assert_eq!(s, StatusCode::OK, "{r}");
assert_eq!(f.post("guilds/bank/credit", credit).await.1, r);
assert_eq!(f.guild_balance().await, 64.0);
let (_, nobody) = f.post("guilds/bank/credit", json!({"operation_id": "sale-2", "uuid": "00000000-0000-0000-0000-000000000009", "username": "X", "amount": 5.0})).await;
let (_, nobody) = f
.post(
"guilds/bank/credit",
json!({"operation_id": "sale-2", "uuid": "00000000-0000-0000-0000-000000000009", "username": "X", "amount": 5.0}),
)
.await;
assert!(nobody["error"].as_str().unwrap().contains("not in a guild"));
let pay = |op: &str, who: &str, tag: &str, amount: f64| json!({"operation_id": op, "uuid": who, "to_tag": tag, "amount": amount});
@@ -103,7 +120,8 @@ async fn shop_sales_and_guild_payments() {
let (s, r) = f.post("guilds/bank/pay", pay("p5", &f.alex_uuid, "void", 24.0)).await;
assert_eq!(s, StatusCode::OK, "{r}");
assert_eq!(r["balance"], 40.0);
let void_balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE guild_id <> ?").bind(&f.gid).fetch_one(&f.t.db).await.unwrap();
let void_balance: f64 =
sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE guild_id <> ?").bind(&f.gid).fetch_one(&f.t.db).await.unwrap();
assert_eq!(void_balance, 24.0);
let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM guild_wallet_transactions ORDER BY id").fetch_all(&f.t.db).await.unwrap();
assert_eq!(kinds, ["sale", "transfer_out", "transfer_in"]);
@@ -126,7 +144,13 @@ async fn guild_market_listings_and_purchases() {
// Mia's guild buys it with guild money: she's an officer but the bank is empty first.
let mia_uuid = f.t.uuid("Mia").await;
let buy = |op: &str| json!({"operation_id": op, "listing_id": listing, "buyer_uuid": mia_uuid, "buyer_name": "Mia", "as_guild": true});
sqlx::query("INSERT INTO guild_wallets (server_id, guild_id, balance, updated_at) SELECT ?, id, 50, 'now' FROM guilds WHERE tag = 'VOID'").bind(f.sid).execute(&f.t.db).await.unwrap();
sqlx::query(
"INSERT INTO guild_wallets (server_id, guild_id, balance, updated_at) SELECT ?, id, 50, 'now' FROM guilds WHERE tag = 'VOID'",
)
.bind(f.sid)
.execute(&f.t.db)
.await
.unwrap();
let (s, e) = f.post("economy/market/buy", buy("b1")).await;
assert_eq!(s, StatusCode::BAD_REQUEST, "{e}");
let still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server_market").fetch_one(&f.t.db).await.unwrap();
@@ -136,7 +160,8 @@ async fn guild_market_listings_and_purchases() {
assert_eq!(s, StatusCode::OK, "{r}");
assert_eq!(r["new_balance"], 380.0);
assert_eq!(f.guild_balance().await, 120.0, "the sale is paid into the listing guild's bank");
let alex_balance: Option<f64> = sqlx::query_scalar("SELECT balance FROM server_economy WHERE uuid = ?").bind(&f.alex_uuid).fetch_optional(&f.t.db).await.unwrap();
let alex_balance: Option<f64> =
sqlx::query_scalar("SELECT balance FROM server_economy WHERE uuid = ?").bind(&f.alex_uuid).fetch_optional(&f.t.db).await.unwrap();
assert!(alex_balance.is_none(), "the lister is not paid personally");
let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM guild_wallet_transactions ORDER BY id").fetch_all(&f.t.db).await.unwrap();
assert_eq!(kinds, ["purchase", "sale"]);
@@ -150,6 +175,7 @@ async fn guild_market_listings_and_purchases() {
let (s, r) = f.post("economy/market/buy", pay).await;
assert_eq!(s, StatusCode::OK, "{r}");
assert_eq!(r["new_balance"], 880.0);
let steve_balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE uuid = ?").bind(&f.steve_uuid).fetch_one(&f.t.db).await.unwrap();
let steve_balance: f64 =
sqlx::query_scalar("SELECT balance FROM server_economy WHERE uuid = ?").bind(&f.steve_uuid).fetch_one(&f.t.db).await.unwrap();
assert_eq!(steve_balance, 1120.0);
}
+14 -5
View File
@@ -6,7 +6,8 @@ async fn leader_approves_join_request_and_role_controls_claims() {
let t = setup().await;
let admin = t.login("admin", "supersecret").await;
for name in ["Leader", "Applicant"] {
let (status, body) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":name,"password":"password123"}))).await;
let (status, body) =
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":name,"password":"password123"}))).await;
assert_eq!(status, StatusCode::OK, "{body}");
}
let leader = t.login("Leader", "password123").await;
@@ -14,7 +15,8 @@ async fn leader_approves_join_request_and_role_controls_claims() {
let applicant_uuid = t.uuid("Applicant").await;
let (_, server) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name":"SMP","instance_id":"smp"}))).await;
let server_id = server["server"]["id"].as_i64().unwrap();
let (status, guild) = t.call("POST", "/api/v1/guilds", Some(&leader), Some(json!({"instance_id":"smp","name":"Pimps","tag":"PIMP"}))).await;
let (status, guild) =
t.call("POST", "/api/v1/guilds", Some(&leader), Some(json!({"instance_id":"smp","name":"Pimps","tag":"PIMP"}))).await;
assert_eq!(status, StatusCode::OK, "{guild}");
let id = guild["id"].as_str().unwrap();
let path = format!("/api/v1/guilds/{id}");
@@ -23,11 +25,18 @@ async fn leader_approves_join_request_and_role_controls_claims() {
assert_eq!(t.call("GET", &format!("{path}/requests"), Some(&applicant), None).await.0, StatusCode::FORBIDDEN);
let (_, requests) = t.call("GET", &format!("{path}/requests"), Some(&leader), None).await;
assert_eq!(requests[0]["message"], "Let me in");
assert_eq!(t.call("POST", &format!("{path}/requests/{applicant_uuid}/respond"), Some(&leader), Some(json!({"accept":true}))).await.0, StatusCode::OK);
assert_eq!(
t.call("POST", &format!("{path}/requests/{applicant_uuid}/respond"), Some(&leader), Some(json!({"accept":true}))).await.0,
StatusCode::OK
);
let (status, role) = t.call("POST", &format!("{path}/roles"), Some(&leader), Some(json!({"name":"Builder","can_claim":false,"can_post":true}))).await;
let (status, role) =
t.call("POST", &format!("{path}/roles"), Some(&leader), Some(json!({"name":"Builder","can_claim":false,"can_post":true}))).await;
assert_eq!(status, StatusCode::OK, "{role}");
assert_eq!(t.call("PUT", &format!("{path}/members/{applicant_uuid}/role"), Some(&leader), Some(json!({"role":"Builder"}))).await.0, StatusCode::OK);
assert_eq!(
t.call("PUT", &format!("{path}/members/{applicant_uuid}/role"), Some(&leader), Some(json!({"role":"Builder"}))).await.0,
StatusCode::OK
);
let claim = json!({"server_id":server_id,"dimension":"minecraft:overworld","chunk_x":1,"chunk_z":1});
assert_eq!(t.call("POST", &format!("{path}/claim"), Some(&applicant), Some(claim)).await.0, StatusCode::FORBIDDEN);
let role_id = role["id"].as_i64().unwrap();
+65 -15
View File
@@ -24,7 +24,14 @@ impl Env {
(self.t.login(name, "password123").await, self.t.uuid(name).await)
}
async fn report(&self, name: &str, data: Value) -> (StatusCode, Value) {
self.t.call("POST", "/api/server/v1/integrations/report", Some(&self.server), Some(json!({"name": name, "version": "5.4.1", "data": data}))).await
self.t
.call(
"POST",
"/api/server/v1/integrations/report",
Some(&self.server),
Some(json!({"name": name, "version": "5.4.1", "data": data})),
)
.await
}
async fn sync(&self, online: &[(&str, &str)]) -> Value {
let online: Vec<Value> = online.iter().map(|(u, n)| json!({"uuid": u, "name": n})).collect();
@@ -48,19 +55,31 @@ async fn luckperms_ranks_mapping_and_sync_modes() {
}
let (_, groups) = e.t.call("GET", "/api/admin/groups", Some(&e.admin), None).await;
let id = |n: &str| groups.as_array().unwrap().iter().find(|g| g["name"] == n).unwrap()["id"].as_i64().unwrap();
let (s, _) = e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("VIP")), Some(&e.admin), Some(json!({"luckperms_group": "vip"}))).await;
let (s, _) =
e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("VIP")), Some(&e.admin), Some(json!({"luckperms_group": "vip"})))
.await;
assert_eq!(s, StatusCode::OK);
let (s, _) = e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("Staff")), Some(&e.admin), Some(json!({"luckperms_group": "bad group!"}))).await;
let (s, _) =
e.t.call(
"PUT",
&format!("/api/admin/groups/{}/luckperms", id("Staff")),
Some(&e.admin),
Some(json!({"luckperms_group": "bad group!"})),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("Staff")), Some(&e.admin), Some(json!({"luckperms_group": "Moderator"}))).await;
e.t.call("PUT", &format!("/api/admin/groups/{}/luckperms", id("Staff")), Some(&e.admin), Some(json!({"luckperms_group": "Moderator"})))
.await;
let lp = |steve_groups: Value| json!({
let lp = |steve_groups: Value| {
json!({
"groups": [{"name": "vip", "weight": 10}, {"name": "moderator", "weight": 50}],
"players": [
{"uuid": steve, "primary": "VIP", "display": "VIP", "prefix": "§6[VIP] ", "weight": 10, "groups": steve_groups, "permissions": ["scopenet.command.*", "essentials.fly"]},
{"uuid": alex, "primary": "default", "groups": ["default"]},
{"uuid": "not-a-uuid", "primary": "x"}
]});
]})
};
// Default mode is display-only: ranks are stored, no group changes.
let (s, r) = e.report("luckperms", lp(json!(["default", "vip"]))).await;
@@ -77,7 +96,8 @@ async fn luckperms_ranks_mapping_and_sync_modes() {
assert_eq!(view["integrations"][0]["data"]["groups"][1]["name"], "moderator");
// game_to_panel: mapped groups follow LuckPerms, including removal.
let (s, _) = e.t.call("PUT", "/api/admin/integrations/settings", Some(&e.admin), Some(json!({"luckperms_sync": "game_to_panel"}))).await;
let (s, _) =
e.t.call("PUT", "/api/admin/integrations/settings", Some(&e.admin), Some(json!({"luckperms_sync": "game_to_panel"}))).await;
assert_eq!(s, StatusCode::OK);
e.report("luckperms", lp(json!(["default", "vip", "moderator"]))).await;
assert_eq!(e.groups_of("Steve").await, ["Staff", "VIP"]);
@@ -86,7 +106,11 @@ async fn luckperms_ranks_mapping_and_sync_modes() {
assert!(r["config"]["assign"].as_array().unwrap().is_empty());
// panel_to_game: the panel's groups are pushed to the game as assignments.
sqlx::query("INSERT INTO user_groups (user_id, group_id) SELECT u.id, ? FROM users u WHERE u.username = 'Alex'").bind(id("VIP")).execute(&e.t.db).await.unwrap();
sqlx::query("INSERT INTO user_groups (user_id, group_id) SELECT u.id, ? FROM users u WHERE u.username = 'Alex'")
.bind(id("VIP"))
.execute(&e.t.db)
.await
.unwrap();
e.t.call("PUT", "/api/admin/integrations/settings", Some(&e.admin), Some(json!({"luckperms_sync": "panel_to_game"}))).await;
let (_, r) = e.report("luckperms", lp(json!(["default", "moderator"]))).await;
let assign = r["config"]["assign"].as_array().unwrap();
@@ -107,7 +131,11 @@ async fn luckperms_ranks_mapping_and_sync_modes() {
async fn other_integrations_are_stored_per_server() {
let e = env().await;
e.report("spark", json!({"tps": {"m1": 19.98}, "mspt": {"mean": 12.5, "p95": 31.0}})).await;
e.report("worldguard", json!({"regions": [{"world": "world", "id": "spawn", "min": [-50, 0, -50], "max": [50, 255, 50], "owners": ["admin"]}]})).await;
e.report(
"worldguard",
json!({"regions": [{"world": "world", "id": "spawn", "min": [-50, 0, -50], "max": [50, 255, 50], "owners": ["admin"]}]}),
)
.await;
e.report("spark", json!({"tps": {"m1": 20.0}})).await;
let (_, view) = e.t.call("GET", &format!("/api/admin/servers/{}/integrations", e.sid), Some(&e.admin), None).await;
let names: Vec<&str> = view["integrations"].as_array().unwrap().iter().map(|i| i["name"].as_str().unwrap()).collect();
@@ -127,7 +155,11 @@ async fn game_servers_receive_events_with_their_sync() {
// Level-ups: global and per-server.
let adjust = |scope: &str, mode: &str, amount: i64| {
let (t, admin, uuid, sid) = (&e.t, e.admin.clone(), steve.clone(), e.sid);
let body = if scope == "global" { json!({"scope": "global", "mode": mode, "amount": amount}) } else { json!({"scope": "server", "server_id": sid, "mode": mode, "amount": amount}) };
let body = if scope == "global" {
json!({"scope": "global", "mode": mode, "amount": amount})
} else {
json!({"scope": "server", "server_id": sid, "mode": mode, "amount": amount})
};
async move { t.call("POST", &format!("/api/admin/progression/players/{uuid}/adjust"), Some(&admin), Some(body)).await }
};
adjust("global", "set_xp", 10).await;
@@ -135,7 +167,8 @@ async fn game_servers_receive_events_with_their_sync() {
adjust("server", "set_xp", 10).await;
adjust("server", "set_xp", 300).await; // level 1 -> 2
let n = e.sync(&[(&steve, "Steve")]).await["notifications"].clone();
let kinds: Vec<(&str, &str)> = n.as_array().unwrap().iter().map(|x| (x["kind"].as_str().unwrap(), x["data"]["scope"].as_str().unwrap_or(""))).collect();
let kinds: Vec<(&str, &str)> =
n.as_array().unwrap().iter().map(|x| (x["kind"].as_str().unwrap(), x["data"]["scope"].as_str().unwrap_or(""))).collect();
assert_eq!(kinds, [("level_up", "global"), ("level_up", "server")], "{n}");
assert_eq!((n[0]["data"]["previous"].as_i64(), n[0]["data"]["level"].as_i64()), (Some(1), Some(5)));
assert_eq!(n[0]["uuid"], steve);
@@ -147,10 +180,27 @@ async fn game_servers_receive_events_with_their_sync() {
// Achievements and guild changes.
let ach: String = sqlx::query_scalar("SELECT id FROM achievements LIMIT 1").fetch_one(&e.t.db).await.unwrap();
sqlx::query("INSERT INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, 'now')").bind(&steve).bind(&ach).execute(&e.t.db).await.unwrap();
sqlx::query("INSERT INTO guilds (id, instance_id, name, tag, leader_uuid, created_at) VALUES ('g1', 'smp', 'Iron', 'IRON', ?, 'now')").bind(&alex).execute(&e.t.db).await.unwrap();
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES ('g1', ?, 'Steve', 'member', 'now')").bind(&steve).execute(&e.t.db).await.unwrap();
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES ('g1', ?, 'Alex', 'leader', 'now')").bind(&alex).execute(&e.t.db).await.unwrap();
sqlx::query("INSERT INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, ?, 'now')")
.bind(&steve)
.bind(&ach)
.execute(&e.t.db)
.await
.unwrap();
sqlx::query("INSERT INTO guilds (id, instance_id, name, tag, leader_uuid, created_at) VALUES ('g1', 'smp', 'Iron', 'IRON', ?, 'now')")
.bind(&alex)
.execute(&e.t.db)
.await
.unwrap();
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES ('g1', ?, 'Steve', 'member', 'now')")
.bind(&steve)
.execute(&e.t.db)
.await
.unwrap();
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES ('g1', ?, 'Alex', 'leader', 'now')")
.bind(&alex)
.execute(&e.t.db)
.await
.unwrap();
sqlx::query("DELETE FROM guild_members WHERE uuid = ?").bind(&steve).execute(&e.t.db).await.unwrap();
let n = e.sync(&[(&steve, "Steve")]).await["notifications"].clone();
let kinds: Vec<&str> = n.as_array().unwrap().iter().map(|x| x["kind"].as_str().unwrap()).collect();
+37 -6
View File
@@ -34,7 +34,13 @@ async fn world() -> World {
impl World {
async fn online(&self, uuid: &str, name: &str) {
sqlx::query("INSERT OR REPLACE INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, ?, '2026-01-01T00:00:00Z')").bind(self.sid).bind(uuid).bind(name).execute(&self.t.db).await.unwrap();
sqlx::query("INSERT OR REPLACE INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, ?, '2026-01-01T00:00:00Z')")
.bind(self.sid)
.bind(uuid)
.bind(name)
.execute(&self.t.db)
.await
.unwrap();
}
async fn poll(&self) -> Value {
self.t.call("POST", "/api/server/v1/actions/poll", Some(&self.server), Some(json!({}))).await.1
@@ -55,7 +61,14 @@ async fn tpa_and_messages_are_queued_once_for_the_game_server() {
w.online(&w.alex_uuid, "Alex").await;
assert_eq!(w.t.call("POST", &path, Some(&w.alex), Some(tpa)).await.0, StatusCode::OK);
let (s, r) = w.t.call("POST", &path, Some(&w.alex), Some(json!({"action": "message", "target_uuid": w.steve_uuid, "text": " hi\n\tthere\u{7} friend "}))).await;
let (s, r) =
w.t.call(
"POST",
&path,
Some(&w.alex),
Some(json!({"action": "message", "target_uuid": w.steve_uuid, "text": " hi\n\tthere\u{7} friend "})),
)
.await;
assert_eq!(s, StatusCode::OK, "{r}");
let polled = w.poll().await;
@@ -70,7 +83,10 @@ async fn tpa_and_messages_are_queued_once_for_the_game_server() {
assert_eq!(bad(json!({"action": "message", "target_uuid": w.steve_uuid, "text": " "})).await.0, StatusCode::BAD_REQUEST);
assert_eq!(bad(json!({"action": "kill", "target_uuid": w.steve_uuid})).await.0, StatusCode::BAD_REQUEST);
assert_eq!(bad(json!({"action": "tpa", "target_uuid": w.alex_uuid})).await.0, StatusCode::BAD_REQUEST);
assert_eq!(w.t.call("POST", &path, None, Some(json!({"action": "tpa", "target_uuid": w.steve_uuid}))).await.0, StatusCode::UNAUTHORIZED);
assert_eq!(
w.t.call("POST", &path, None, Some(json!({"action": "tpa", "target_uuid": w.steve_uuid}))).await.0,
StatusCode::UNAUTHORIZED
);
// A burst is slowed down.
let mut last = StatusCode::OK;
@@ -91,7 +107,14 @@ async fn guild_invites_need_consent() {
assert_eq!(s, StatusCode::OK, "{r}");
let in_guild = |uuid: &str| {
let (db, gid, uuid) = (w.t.db.clone(), w.gid.clone(), uuid.to_string());
async move { sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)").bind(gid).bind(uuid).fetch_one(&db).await.unwrap() }
async move {
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM guild_members WHERE guild_id = ? AND uuid = ?)")
.bind(gid)
.bind(uuid)
.fetch_one(&db)
.await
.unwrap()
}
};
assert!(!in_guild(&w.steve_uuid).await, "nobody is added without saying yes");
@@ -125,14 +148,22 @@ async fn invites_work_in_game_too() {
};
let (s, r) = server("guilds/invite/send", json!({"uuid": w.alex_uuid, "target": "steve"})).await;
assert_eq!(s, StatusCode::OK, "{r}");
assert_eq!(server("guilds/invite/send", json!({"uuid": w.steve_uuid, "target": "alex"})).await.0, StatusCode::FORBIDDEN, "not a leader");
assert_eq!(
server("guilds/invite/send", json!({"uuid": w.steve_uuid, "target": "alex"})).await.0,
StatusCode::FORBIDDEN,
"not a leader"
);
assert_eq!(server("guilds/invite/send", json!({"uuid": w.alex_uuid, "target": "nobody"})).await.0, StatusCode::NOT_FOUND);
let (_, list) = server("guilds/invite/list", json!({"uuid": w.steve_uuid})).await;
assert_eq!(list["invites"][0]["guild_tag"], "IRON");
let (s, r) = server("guilds/invite/respond", json!({"uuid": w.steve_uuid, "tag": "iron", "accept": false})).await;
assert_eq!((s, r["accepted"].clone()), (StatusCode::OK, json!(false)));
assert_eq!(server("guilds/invite/respond", json!({"uuid": w.steve_uuid, "accept": true})).await.0, StatusCode::NOT_FOUND, "a declined invitation is gone");
assert_eq!(
server("guilds/invite/respond", json!({"uuid": w.steve_uuid, "accept": true})).await.0,
StatusCode::NOT_FOUND,
"a declined invitation is gone"
);
server("guilds/invite/send", json!({"uuid": w.alex_uuid, "target": "Steve"})).await;
let (s, r) = server("guilds/invite/respond", json!({"uuid": w.steve_uuid, "accept": true})).await;
+25 -3
View File
@@ -13,14 +13,29 @@ async fn servers_in_an_economy_group_share_balances() {
let mut tokens = vec![];
let mut ids = vec![];
for (name, group) in [("SMP", "network"), ("Survival", "Network"), ("Modded", "")] {
let (s, r) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": name, "instance_id": name.to_lowercase(), "economy_group": group}))).await;
let (s, r) = t
.call(
"POST",
"/api/admin/servers",
Some(&admin),
Some(json!({"name": name, "instance_id": name.to_lowercase(), "economy_group": group})),
)
.await;
assert_eq!(s, StatusCode::OK, "{r}");
tokens.push(r["token"].as_str().unwrap().to_string());
ids.push(r["server"]["id"].as_i64().unwrap());
}
let adjust = |token: String, op: &'static str, delta: f64| {
let (t, uuid) = (&t, uuid.clone());
async move { t.call("POST", "/api/server/v1/economy/adjust", Some(&token), Some(json!({"uuid": uuid, "username": "Alex", "delta": delta, "operation_id": op, "description": "test"}))).await }
async move {
t.call(
"POST",
"/api/server/v1/economy/adjust",
Some(&token),
Some(json!({"uuid": uuid, "username": "Alex", "delta": delta, "operation_id": op, "description": "test"})),
)
.await
}
};
let balance = |token: String| {
let (t, uuid) = (&t, uuid.clone());
@@ -38,7 +53,14 @@ async fn servers_in_an_economy_group_share_balances() {
assert_eq!(balance(tokens[2].clone()).await.1["balance"], 1000.0);
// Bad group names are refused.
let (s, _) = t.call("PUT", &format!("/api/admin/servers/{}", ids[2]), Some(&admin), Some(json!({"name": "Modded", "instance_id": "modded", "economy_group": "a<b>"}))).await;
let (s, _) = t
.call(
"PUT",
&format!("/api/admin/servers/{}", ids[2]),
Some(&admin),
Some(json!({"name": "Modded", "instance_id": "modded", "economy_group": "a<b>"})),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// Removing the server that holds the shared balances keeps them for the rest of the group.
+8 -2
View File
@@ -20,7 +20,12 @@ async fn profile_shows_the_whole_player() {
sqlx::query("INSERT INTO friendships (user_uuid, friend_uuid, status, action_uuid, created_at, updated_at) VALUES (?, ?, 'accepted', ?, 'x', 'x')").bind(a).bind(b).bind(a).execute(&t.db).await.unwrap();
}
t.call("POST", "/api/v1/guilds", Some(&steve), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 4321.5, 'x')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, 'Steve', 4321.5, 'x')")
.bind(sid)
.bind(&su)
.execute(&t.db)
.await
.unwrap();
sqlx::query("INSERT INTO player_ranks (server_id, uuid, primary_group, display, prefix, groups, permissions, weight, updated_at) VALUES (?, ?, 'vip', 'VIP', '[VIP]', '[\"vip\"]', '[]', 10, 'x')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO player_stats (server_id, uuid, name, playtime_secs, first_seen, last_seen) VALUES (?, ?, 'Steve', 7200, 'x', '2026-01-01T00:00:00Z')").bind(sid).bind(&su).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO server_events (server_id, uuid, name, kind, detail, created_at) VALUES (?, ?, 'Steve', 'join', NULL, '2026-01-01T00:00:00Z'), (?, ?, 'Steve', 'chat', 'secret', '2026-01-01T00:00:01Z')").bind(sid).bind(&su).bind(sid).bind(&su).execute(&t.db).await.unwrap();
@@ -43,6 +48,7 @@ async fn profile_shows_the_whole_player() {
let (s, _) = t.call("PUT", "/api/v1/profiles/me", Some(&alex), Some(json!({"bio": "hi", "accent_color": "#22D3EE"}))).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(t.call("GET", &format!("/api/v1/profiles/{au}"), Some(&alex), None).await.1["accent_color"], "#22d3ee");
let (s, _) = t.call("PUT", "/api/v1/profiles/me", Some(&alex), Some(json!({"bio": "hi", "accent_color": "red; background:url(x)"}))).await;
let (s, _) =
t.call("PUT", "/api/v1/profiles/me", Some(&alex), Some(json!({"bio": "hi", "accent_color": "red; background:url(x)"}))).await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
+7 -1
View File
@@ -47,6 +47,12 @@ async fn the_landing_page_decides_what_the_public_sees_of_the_map() {
// Another server's map isn't exposed, and turning the section off closes it.
assert_eq!(t.call("GET", "/api/v1/public/map/999", None, None).await.0, StatusCode::NOT_FOUND);
t.call("PUT", "/api/admin/landing", Some(&admin), Some(json!({"blocks": [{"id": "m1", "type": "map", "enabled": false, "options": {}}]}))).await;
t.call(
"PUT",
"/api/admin/landing",
Some(&admin),
Some(json!({"blocks": [{"id": "m1", "type": "map", "enabled": false, "options": {}}]})),
)
.await;
assert_eq!(t.call("GET", "/api/v1/public/map/0", None, None).await.0, StatusCode::NOT_FOUND);
}
+31 -4
View File
@@ -15,14 +15,28 @@ async fn renaming_a_rank_title_reaches_players() {
.bind(sid).bind(&uuid).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO user_levels (uuid, global_xp, global_level, updated_at) VALUES (?, 100000, 10, 'now') ON CONFLICT(uuid) DO UPDATE SET global_xp = 100000").bind(&uuid).execute(&t.db).await.unwrap();
let (s, made) = t.call("POST", "/api/admin/rewards", Some(&admin), Some(json!({"level_type":"server","server_id":sid,"level_req":2,"reward_type":"title","reward_name":"Squire"}))).await;
let (s, made) = t
.call(
"POST",
"/api/admin/rewards",
Some(&admin),
Some(json!({"level_type":"server","server_id":sid,"level_req":2,"reward_type":"title","reward_name":"Squire"})),
)
.await;
assert_eq!(s, StatusCode::OK, "{made}");
let id = made["id"].as_i64().unwrap();
let rank = |v: &Value| v["server_levels"][0]["rank_name"].clone();
let (_, levels) = t.call("GET", &format!("/api/v1/levels/player/{uuid}"), Some(&steve), None).await;
assert_eq!(rank(&levels), "Squire", "{levels}");
let (s, r) = t.call("PUT", &format!("/api/admin/rewards/{id}"), Some(&admin), Some(json!({"level_type":"server","server_id":sid,"level_req":2,"reward_type":"title","reward_name":"Knight"}))).await;
let (s, r) = t
.call(
"PUT",
&format!("/api/admin/rewards/{id}"),
Some(&admin),
Some(json!({"level_type":"server","server_id":sid,"level_req":2,"reward_type":"title","reward_name":"Knight"})),
)
.await;
assert_eq!(s, StatusCode::OK, "{r}");
let (_, levels) = t.call("GET", &format!("/api/v1/levels/player/{uuid}"), Some(&steve), None).await;
assert_eq!(rank(&levels), "Knight", "a renamed rank shows up straight away");
@@ -40,11 +54,24 @@ async fn renaming_a_global_title_reaches_the_profile_and_reward_list() {
let steve = t.login("Steve", "password123").await;
let uuid = t.uuid("Steve").await;
sqlx::query("INSERT INTO user_levels (uuid, global_xp, global_level, updated_at) VALUES (?, 100000, 10, 'now') ON CONFLICT(uuid) DO UPDATE SET global_xp = 100000").bind(&uuid).execute(&t.db).await.unwrap();
let (_, made) = t.call("POST", "/api/admin/levels/rewards", Some(&admin), Some(json!({"level": 100, "reward_type": "title", "reward_value": "Explorer"}))).await;
let (_, made) = t
.call(
"POST",
"/api/admin/levels/rewards",
Some(&admin),
Some(json!({"level": 100, "reward_type": "title", "reward_value": "Explorer"})),
)
.await;
let id = made["id"].as_i64().unwrap();
let (_, me) = t.call("GET", "/api/v1/levels/me", Some(&steve), None).await;
assert_eq!(me["title"], "Explorer", "{me}");
t.call("PUT", &format!("/api/admin/levels/rewards/{id}"), Some(&admin), Some(json!({"level": 100, "reward_type": "title", "reward_value": "Pathfinder"}))).await;
t.call(
"PUT",
&format!("/api/admin/levels/rewards/{id}"),
Some(&admin),
Some(json!({"level": 100, "reward_type": "title", "reward_value": "Pathfinder"})),
)
.await;
let (_, me) = t.call("GET", "/api/v1/levels/me", Some(&steve), None).await;
assert_eq!(me["title"], "Pathfinder", "{me}");
let rewards = me["rewards"].to_string();
+92 -18
View File
@@ -24,16 +24,29 @@ async fn an_achievement_can_pay_money_chunks_items_and_permissions() {
// Bad bundles are refused; good ones are normalised.
for a in [json!({"type": "money", "amount": -5}), json!({"type": "item", "item": "bad item!", "amount": 1}), json!({"type": "nope"})] {
assert_eq!(t.call("PUT", "/api/admin/reward-bundles/achievement/ach_test", Some(&admin), Some(json!({"actions": [a]}))).await.0, StatusCode::BAD_REQUEST);
assert_eq!(
t.call("PUT", "/api/admin/reward-bundles/achievement/ach_test", Some(&admin), Some(json!({"actions": [a]}))).await.0,
StatusCode::BAD_REQUEST
);
}
assert_eq!(t.call("PUT", "/api/admin/reward-bundles/banana/x", Some(&admin), Some(json!({"actions": []}))).await.0, StatusCode::BAD_REQUEST);
let (s, saved) = t.call("PUT", "/api/admin/reward-bundles/achievement/ach_test", Some(&admin), Some(json!({"actions": [
assert_eq!(
t.call("PUT", "/api/admin/reward-bundles/banana/x", Some(&admin), Some(json!({"actions": []}))).await.0,
StatusCode::BAD_REQUEST
);
let (s, saved) = t
.call(
"PUT",
"/api/admin/reward-bundles/achievement/ach_test",
Some(&admin),
Some(json!({"actions": [
{"type": "money", "amount": 250},
{"type": "claim_chunks", "amount": 8},
{"type": "item", "item": "diamond", "amount": 3},
{"type": "permission", "node": "essentials.fly", "minutes": 10080},
{"type": "group", "group": "vip"},
{"type": "message", "text": "Well done!"}]}))).await;
{"type": "message", "text": "Well done!"}]})),
)
.await;
assert_eq!(s, StatusCode::OK, "{saved}");
assert_eq!(saved["actions"][2]["item"], "minecraft:diamond");
@@ -44,17 +57,32 @@ async fn an_achievement_can_pay_money_chunks_items_and_permissions() {
assert_eq!(sums["achievement:ach_test"][3], "Permission: essentials.fly for 7 days");
// Earning it applies the panel-side rewards straight away…
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Steve', 'now')").bind(sid).bind(&steve).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, 'ach_test', 'now')").bind(&steve).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Steve', 'now')")
.bind(sid)
.bind(&steve)
.execute(&t.db)
.await
.unwrap();
sqlx::query("INSERT INTO user_achievements (user_uuid, achievement_id, unlocked_at) VALUES (?, 'ach_test', 'now')")
.bind(&steve)
.execute(&t.db)
.await
.unwrap();
let (s, polled) = game("rewards/poll", json!({})).await;
assert_eq!(s, StatusCode::OK, "{polled}");
let kinds: Vec<String> = polled["deliveries"].as_array().unwrap().iter().map(|d| d["kind"].as_str().unwrap().to_string()).collect();
assert_eq!(kinds, ["item", "permission", "group", "message"], "{polled}");
assert_eq!(polled["deliveries"][0]["payload"]["item"], "minecraft:diamond");
assert_eq!(polled["deliveries"][0]["name"], "Steve");
let balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(sid).bind(&steve).fetch_one(&t.db).await.unwrap();
let balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
.bind(sid)
.bind(&steve)
.fetch_one(&t.db)
.await
.unwrap();
assert_eq!(balance, 1250.0, "the starting 1000 plus 250");
let chunks: i64 = sqlx::query_scalar("SELECT claim_chunks FROM player_bonuses WHERE uuid = ?").bind(&steve).fetch_one(&t.db).await.unwrap();
let chunks: i64 =
sqlx::query_scalar("SELECT claim_chunks FROM player_bonuses WHERE uuid = ?").bind(&steve).fetch_one(&t.db).await.unwrap();
assert_eq!(chunks, 8);
// …and a delivery is handed out once until acknowledged.
@@ -69,7 +97,8 @@ async fn an_achievement_can_pay_money_chunks_items_and_permissions() {
assert_eq!(log.iter().filter(|d| !d["delivered_at"].is_null()).count(), 2);
// The extra chunks lift the player's guild's claim limit.
let (_, g) = t.call("POST", "/api/v1/guilds", Some(&steve_token), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
let (_, g) =
t.call("POST", "/api/v1/guilds", Some(&steve_token), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
let (_, detail) = t.call("GET", &format!("/api/v1/guilds/{}", g["id"].as_str().unwrap()), Some(&steve_token), None).await;
assert_eq!(detail["max_claims"].as_i64(), Some(16 + 8), "{detail}");
}
@@ -86,7 +115,12 @@ async fn deliveries_wait_for_the_player_to_be_online() {
sqlx::query("INSERT INTO reward_deliveries (uuid, kind, payload, source, created_at) VALUES (?, 'item', '{\"type\":\"item\",\"item\":\"minecraft:apple\",\"amount\":1}', 'x', 'now')").bind(&alex).execute(&t.db).await.unwrap();
let poll = || async { t.call("POST", "/api/server/v1/rewards/poll", Some(&key), Some(json!({}))).await.1 };
assert!(poll().await["deliveries"].as_array().unwrap().is_empty(), "offline players get nothing yet");
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Alex', 'now')").bind(sid).bind(&alex).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Alex', 'now')")
.bind(sid)
.bind(&alex)
.execute(&t.db)
.await
.unwrap();
assert_eq!(poll().await["deliveries"].as_array().unwrap().len(), 1);
}
@@ -99,22 +133,62 @@ async fn a_rank_milestone_can_grant_more_than_its_title() {
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await;
let sid = srv["server"]["id"].as_i64().unwrap();
let key = srv["token"].as_str().unwrap().to_string();
let (_, made) = t.call("POST", "/api/admin/rewards", Some(&admin), Some(json!({"level_type": "global", "level_req": 3, "reward_type": "title", "reward_name": "Veteran"}))).await;
let (_, made) = t
.call(
"POST",
"/api/admin/rewards",
Some(&admin),
Some(json!({"level_type": "global", "level_req": 3, "reward_type": "title", "reward_name": "Veteran"})),
)
.await;
let id = made["id"].as_i64().unwrap();
let (s, _) = t.call("PUT", &format!("/api/admin/reward-bundles/level_reward/{id}"), Some(&admin), Some(json!({"actions": [
{"type": "money", "amount": 500}, {"type": "command", "command": "/give {player} cake 1"}]}))).await;
let (s, _) = t
.call(
"PUT",
&format!("/api/admin/reward-bundles/level_reward/{id}"),
Some(&admin),
Some(json!({"actions": [
{"type": "money", "amount": 500}, {"type": "command", "command": "/give {player} cake 1"}]})),
)
.await;
assert_eq!(s, StatusCode::OK);
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Alex', 'now')").bind(sid).bind(&alex).execute(&t.db).await.unwrap();
let (s, r) = t.call("POST", "/api/server/v1/player/xp", Some(&key), Some(json!({"operation_id": "lv", "uuid": alex, "scope": "global", "amount": 100000}))).await;
sqlx::query("INSERT INTO server_online (server_id, uuid, name, joined_at) VALUES (?, ?, 'Alex', 'now')")
.bind(sid)
.bind(&alex)
.execute(&t.db)
.await
.unwrap();
let (s, r) = t
.call(
"POST",
"/api/server/v1/player/xp",
Some(&key),
Some(json!({"operation_id": "lv", "uuid": alex, "scope": "global", "amount": 100000})),
)
.await;
assert_eq!(s, StatusCode::OK, "{r}");
let (_, polled) = t.call("POST", "/api/server/v1/rewards/poll", Some(&key), Some(json!({}))).await;
assert_eq!(polled["deliveries"][0]["kind"], "command", "{polled}");
assert_eq!(polled["deliveries"][0]["payload"]["command"], "give {player} cake 1");
let balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(sid).bind(&alex).fetch_one(&t.db).await.unwrap();
let balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
.bind(sid)
.bind(&alex)
.fetch_one(&t.db)
.await
.unwrap();
assert_eq!(balance, 1500.0);
// Earning the same milestone twice pays once.
t.call("POST", "/api/server/v1/player/xp", Some(&key), Some(json!({"operation_id": "lv2", "uuid": alex, "scope": "global", "amount": 100}))).await;
assert!(t.call("POST", "/api/server/v1/rewards/poll", Some(&key), Some(json!({}))).await.1["deliveries"].as_array().unwrap().is_empty());
t.call(
"POST",
"/api/server/v1/player/xp",
Some(&key),
Some(json!({"operation_id": "lv2", "uuid": alex, "scope": "global", "amount": 100})),
)
.await;
assert!(t.call("POST", "/api/server/v1/rewards/poll", Some(&key), Some(json!({}))).await.1["deliveries"]
.as_array()
.unwrap()
.is_empty());
}
#[tokio::test]
+42 -9
View File
@@ -68,12 +68,20 @@ async fn search_befriend_message_invite() {
/// Every table and column is scanned, so a table added later is covered too.
async fn mentions(t: &TestApp, needle: &str) -> Vec<String> {
let tables: Vec<String> = sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").fetch_all(&t.db).await.unwrap();
let tables: Vec<String> = sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.fetch_all(&t.db)
.await
.unwrap();
let mut hits = Vec::new();
for table in tables {
let cols: Vec<String> = sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")).fetch_all(&t.db).await.unwrap();
let cols: Vec<String> =
sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")).fetch_all(&t.db).await.unwrap();
for col in cols {
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM \"{table}\" WHERE CAST(\"{col}\" AS TEXT) = ?")).bind(needle).fetch_one(&t.db).await.unwrap();
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM \"{table}\" WHERE CAST(\"{col}\" AS TEXT) = ?"))
.bind(needle)
.fetch_one(&t.db)
.await
.unwrap();
if n > 0 {
hits.push(format!("{table}.{col}"));
}
@@ -130,10 +138,16 @@ async fn deleting_an_account_removes_all_its_data() {
.bind(sid).bind(&steve_uuid).execute(&t.db).await.unwrap();
// Guilds: Steve leads one with Alex in it, and is sole member of another.
let (s, g) = t.call("POST", "/api/v1/guilds", Some(&steve), Some(json!({"instance_id": "smp", "name": "Rockheads", "tag": "ROCK"}))).await;
let (s, g) =
t.call("POST", "/api/v1/guilds", Some(&steve), Some(json!({"instance_id": "smp", "name": "Rockheads", "tag": "ROCK"}))).await;
assert_eq!(s, StatusCode::OK, "{g}");
let gid = g["id"].as_str().unwrap().to_string();
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Alex', 'member', '2026-01-01')").bind(&gid).bind(&alex_uuid).execute(&t.db).await.unwrap();
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Alex', 'member', '2026-01-01')")
.bind(&gid)
.bind(&alex_uuid)
.execute(&t.db)
.await
.unwrap();
sqlx::query("INSERT INTO guild_wallet_transactions (server_id, guild_id, actor_uuid, kind, amount, created_at) VALUES (?, ?, ?, 'deposit', 5, 'now')").bind(sid).bind(&gid).bind(&steve_uuid).execute(&t.db).await.unwrap();
let (s, g2) = t.call("POST", "/api/v1/guilds", Some(&admin), Some(json!({"instance_id": "smp", "name": "Solo", "tag": "SOLO"}))).await;
assert_eq!(s, StatusCode::OK, "{g2}");
@@ -147,11 +161,26 @@ async fn deleting_an_account_removes_all_its_data() {
// Nothing refers to them any more (their name appears only as plain text in free-form rows we don't keep).
assert_eq!(mentions(&t, &steve_uuid).await, vec!["reserved_usernames.uuid".to_string()]);
for table in ["users", "user_levels", "player_stats", "friendships", "direct_messages", "user_profiles", "user_posts", "server_market", "server_economy"] {
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table} WHERE CAST(uuid AS TEXT) = ?")).bind(&steve_uuid).fetch_one(&t.db).await.unwrap_or(0);
for table in [
"users",
"user_levels",
"player_stats",
"friendships",
"direct_messages",
"user_profiles",
"user_posts",
"server_market",
"server_economy",
] {
let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table} WHERE CAST(uuid AS TEXT) = ?"))
.bind(&steve_uuid)
.fetch_one(&t.db)
.await
.unwrap_or(0);
assert_eq!(n, 0, "{table}");
}
let name_hits: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'Steve' COLLATE NOCASE").fetch_one(&t.db).await.unwrap();
let name_hits: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'Steve' COLLATE NOCASE").fetch_one(&t.db).await.unwrap();
assert_eq!(name_hits, 0);
// Other people keep what is theirs: Alex is now the guild's leader; the
@@ -160,7 +189,11 @@ async fn deleting_an_account_removes_all_its_data() {
assert_eq!(leader, alex_uuid);
let from: String = sqlx::query_scalar("SELECT from_name FROM economy_transactions").fetch_one(&t.db).await.unwrap();
assert_eq!(from, "Deleted player");
let likes: i64 = sqlx::query_scalar("SELECT likes_count FROM user_posts WHERE id = ?").bind(alex_post["id"].as_i64().unwrap()).fetch_one(&t.db).await.unwrap();
let likes: i64 = sqlx::query_scalar("SELECT likes_count FROM user_posts WHERE id = ?")
.bind(alex_post["id"].as_i64().unwrap())
.fetch_one(&t.db)
.await
.unwrap();
assert_eq!(likes, 0);
let (_, friends) = t.call("GET", "/api/v1/friends", Some(&alex), None).await;
assert!(friends.as_array().unwrap().is_empty());
+17 -3
View File
@@ -102,7 +102,10 @@ async fn players_and_overlay_are_served_to_viewers() {
assert_eq!(s, StatusCode::OK);
let overlay = json!({"claims": [{"guild_id": "g1", "name": "Iron", "tag": "IRON", "color": 123}], "pins": [{"id": "spawn-0", "kind": "spawn", "label": "Spawn"}]});
assert_eq!(w.t.call("POST", "/api/server/v1/map/overlay", Some(&w.server), Some(overlay)).await.0, StatusCode::OK);
assert_eq!(w.t.call("POST", "/api/server/v1/map/overlay", Some(&w.server), Some(json!({"claims": "nope"}))).await.0, StatusCode::BAD_REQUEST);
assert_eq!(
w.t.call("POST", "/api/server/v1/map/overlay", Some(&w.server), Some(json!({"claims": "nope"}))).await.0,
StatusCode::BAD_REQUEST
);
let (s, o) = w.t.call("GET", &format!("/api/v1/servers/{}/map/overlay", w.sid), Some(&w.alex), None).await;
assert_eq!(s, StatusCode::OK);
@@ -124,9 +127,20 @@ async fn the_map_can_be_switched_off_and_reset() {
assert_eq!(cfg["epoch"], 1, "game servers see a new epoch and redraw");
let (_, info) = w.t.call("GET", &format!("/api/v1/servers/{}/map", w.sid), Some(&w.alex), None).await;
assert_eq!(info["ready"], false);
assert_eq!(w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.alex), None).await.0, StatusCode::FORBIDDEN, "admin only");
assert_eq!(
w.t.call("GET", &format!("/api/admin/servers/{}/map", w.sid), Some(&w.alex), None).await.0,
StatusCode::FORBIDDEN,
"admin only"
);
let (s, _) = w.t.call("PUT", &format!("/api/admin/servers/{}", w.sid), Some(&w.admin), Some(json!({"name": "SMP", "instance_id": "smp", "map_enabled": false}))).await;
let (s, _) =
w.t.call(
"PUT",
&format!("/api/admin/servers/{}", w.sid),
Some(&w.admin),
Some(json!({"name": "SMP", "instance_id": "smp", "map_enabled": false})),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(w.upload("minecraft:overworld", record(0, 0, 0, b"a")).await.0, StatusCode::FORBIDDEN);
let (_, cfg) = w.t.call("GET", "/api/server/v1/map/config", Some(&w.server), None).await;
+9 -9
View File
@@ -1,9 +1,9 @@
5e931abbb8b037c8de349afdd0b4165c23059e58672f7e0a11a27c4f70db6bed scopenet-client-fabric-1.20.1-0.5.0.jar
cb8690f842f0efb7a336c568a12e11c5bc9da2986ab68f337d4ca211c99cecb5 scopenet-fabric-1.20.1-0.5.0.jar
e1e6650e05f5a66655f88be8e892095e79ddf8cd5502affea9bd68033e33cc21 scopenet-fabric-1.21.1-0.5.0.jar
a54a1478c0740eb4aef20797037738355e3482ac1f4eaaa9b408d16e2303b7ed scopenet-fabric-26.3-0.5.0.jar
d9a22725d0b2554f31e010a6f596a73c023b339490ef982b5d53e8f866a8c447 scopenet-forge-1.20.1-0.5.0.jar
49098a90919fdea02f4eec26e332cd6a51fc7bb398ba1d55f87528f52610e250 scopenet-forge-1.21.1-0.5.0.jar
31e5076947f33ad1ec0037d92736b28eb8a47e4be84a6bacbf1dfd11308f579a scopenet-forge-26.3-0.5.0.jar
1f4428de07b3061862c5dac3c7214ae87f9525385533a8c244ab67ec31ee76da scopenet-paper-0.5.0.jar
3ade714b0869b9fbb0e6175280b69d5bb6531543ce8ce48c7f231f170081dd5f SCOPENET Launcher_0.5.0_x64-setup.exe
305118fa405699992c608cd68275eeae59b9304727a59f445d04bf2a3c639131 scopenet-client-fabric-1.20.1-0.5.0.jar
185a0e755240dd1f2ea281ca887ccb6b519063ac17f4e81b7133af06af268928 scopenet-fabric-1.20.1-0.5.0.jar
1a1db2db43bf89dbd5e5d20ca747e5c8550e0eb6fe4ac2b921bef22523990efa scopenet-fabric-1.21.1-0.5.0.jar
297ec651cfb432ae0e112076c3e95c32832b6e8fa9848fb93f81ff855d595264 scopenet-fabric-26.3-0.5.0.jar
dae2b854d84b6e76b1389f27534c0724a72cf24244bb3f990c84d2d07d54a210 scopenet-forge-1.20.1-0.5.0.jar
13b1588fe514d2f0b3f3c8fdd658b9a58fb3b31a94eb6fc214ace491b500d411 scopenet-forge-1.21.1-0.5.0.jar
8d8b5b533f623b8c96acd2207f0a95f60e8d20963e49240d1dd3df60eae0689b scopenet-forge-26.3-0.5.0.jar
2d9a3f8e997e76e7312870d9a515056de668cc563c809f7d61025d734193858e scopenet-paper-0.5.0.jar
beaa1e906a3dc05292bf04e3fb592b2b53038fb1ab0e0214182e9829edced21e SCOPENET Launcher_0.5.0_x64-setup.exe
Binary file not shown.