diff --git a/crates/core/src/options.rs b/crates/core/src/options.rs index e8447aa..16c5450 100644 --- a/crates/core/src/options.rs +++ b/crates/core/src/options.rs @@ -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> { @@ -28,10 +77,13 @@ pub fn read_vanilla_preferences(game_dir: &Path) -> Result 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_:` 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")); diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index c855873..e6bc5a3 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -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, diff --git a/integrations/gradlew b/integrations/gradlew old mode 100644 new mode 100755 diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index d6d75c1..92644bb 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -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 { 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 { 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 { +pub async fn transfer_guild_wallet( + state: State<'_, AppState>, + guild_id: String, + server_id: i64, + amount: f64, + withdraw: bool, +) -> Res { 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 Option { 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(()) } diff --git a/panel/server/src/embeds.rs b/panel/server/src/embeds.rs index 4c735d8..bd71952 100644 --- a/panel/server/src/embeds.rs +++ b/panel/server/src/embeds.rs @@ -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::()) } + if s.chars().count() <= max { + s.to_string() + } else { + format!("{}…", s.chars().take(max.saturating_sub(1)).collect::()) + } } 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)> = 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)> = 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(>)); @@ -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 { - 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 { 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.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.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); diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs index 7970f5d..4969260 100644 --- a/panel/server/src/lib.rs +++ b/panel/server/src/lib.rs @@ -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}; diff --git a/panel/server/src/progression.rs b/panel/server/src/progression.rs index 0e8f5e2..a39aab1 100644 --- a/panel/server/src/progression.rs +++ b/panel/server/src/progression.rs @@ -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(()) } diff --git a/panel/server/src/purge.rs b/panel/server/src/purge.rs index 0215fca..145fef4 100644 --- a/panel/server/src/purge.rs +++ b/panel/server/src/purge.rs @@ -66,7 +66,10 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult AppResult 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, key: &str, max: usize) -> String { @@ -126,7 +130,13 @@ pub fn validate(actions: &Value) -> AppResult> { 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::() + c.as_str()).unwrap_or_default() }).collect::>().join(" ") + name.split_whitespace() + .map(|w| { + let mut c = w.chars(); + c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() + }) + .collect::>() + .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> { - let raw: Option = 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 = 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::>(&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, note: &str) -> AppResult<()> { +async fn credit( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + uuid: &str, + name: &str, + amount: f64, + server: Option, + note: &str, +) -> AppResult<()> { // One credit per economy: servers sharing a group share balances, so they must not be paid twice. let servers: Vec = 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 { - 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 = sqlx::query_scalar("DELETE FROM reward_queue WHERE id = ? RETURNING id").bind(queue_id).fetch_optional(&mut *tx).await?; + let gone: Option = + 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 { 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 = 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 { 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> { .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::(&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(()) diff --git a/panel/server/src/routes/achievements.rs b/panel/server/src/routes/achievements.rs index b3631f6..d3bd814 100644 --- a/panel/server/src/routes/achievements.rs +++ b/panel/server/src/routes/achievements.rs @@ -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, -) -> AppResult>> { +pub async fn get_my_achievements(auth: AuthUser, State(state): State) -> AppResult>> { 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, - State(state): State, -) -> AppResult>> { +pub async fn get_player_achievements(Path(uuid): Path, State(state): State) -> AppResult>> { 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> { - let rows: Vec<( - String, - String, - String, - String, - String, - String, - String, - String, - i64, - bool, - Option, - )> = sqlx::query_as( +pub async fn get_achievements_for_user(state: &AppState, uuid: &str, unlocked_only: bool) -> AppResult> { + let rows: Vec<(String, String, String, String, String, String, String, String, i64, bool, Option)> = 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, -) -> AppResult>> { - 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) -> AppResult>> { + 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, Json(payload): Json, ) -> AppResult> { - 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, - State(state): State, -) -> AppResult> { +pub async fn admin_delete_achievement(_admin: AdminUser, Path(id): Path, State(state): State) -> AppResult> { 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 }))) } diff --git a/panel/server/src/routes/admin.rs b/panel/server/src/routes/admin.rs index 237d06f..a6460a2 100644 --- a/panel/server/src/routes/admin.rs +++ b/panel/server/src/routes/admin.rs @@ -234,7 +234,14 @@ pub async fn delete_user(AdminUser(me): AdminUser, State(state): State .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 }))) } diff --git a/panel/server/src/routes/dev_api.rs b/panel/server/src/routes/dev_api.rs index c952df1..81779e7 100644 --- a/panel/server/src/routes/dev_api.rs +++ b/panel/server/src/routes/dev_api.rs @@ -22,7 +22,8 @@ fn dashed_or_err(uuid: &str) -> AppResult { /// Everything a placeholder or `getPlayer()` needs in one round trip. pub async fn player_info(GameServer(server): GameServer, State(state): State, Json(p): Json) -> AppResult> { 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, Option, Option) = - 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,) = 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,) = 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 = 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 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 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::()); - 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::() + ); + 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, Json(p): Json) -> AppResult> { +pub async fn quest_objective( + GameServer(server): GameServer, + State(state): State, + Json(p): Json, +) -> AppResult> { 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, 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::>() }))) + 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::>() }), + )) } #[derive(Deserialize)] @@ -213,16 +269,41 @@ pub struct GuildQuery { pub async fn guild(GameServer(server): GameServer, State(state): State, Json(q): Json) -> AppResult> { let id: Option = 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, diff --git a/panel/server/src/routes/discord.rs b/panel/server/src/routes/discord.rs index eb9bdd9..795e5d4 100644 --- a/panel/server/src/routes/discord.rs +++ b/panel/server/src/routes/discord.rs @@ -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 { 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) -> AppResult> { 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, Json(i): pub async fn public_info(State(state): State, AuthUser(user): AuthUser) -> AppResult> { 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, Path(id): Path, Json(m): Json) -> AppResult> { +pub async fn set_role_mapping( + _: AdminUser, + State(state): State, + Path(id): Path, + Json(m): Json, +) -> AppResult> { 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 { 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 { 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::().await else { report.failed += 1; continue; }; - let roles: Vec = member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default(); - let groups: Vec = sqlx::query_scalar("SELECT group_id FROM user_groups WHERE user_id = ?").bind(user_id).fetch_all(&state.db).await?; + let roles: Vec = + member["roles"].as_array().map(|a| a.iter().filter_map(|r| r.as_str().map(String::from)).collect()).unwrap_or_default(); + let groups: Vec = + 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 { } if let Some(chosen) = chosen { let mut managed: Vec = 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 { .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 { } } 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 { } } 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; diff --git a/panel/server/src/routes/discord_studio.rs b/panel/server/src/routes/discord_studio.rs index d9e28f7..65ffa76 100644 --- a/panel/server/src/routes/discord_studio.rs +++ b/panel/server/src/routes/discord_studio.rs @@ -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] { diff --git a/panel/server/src/routes/economy.rs b/panel/server/src/routes/economy.rs index 830eb21..eba3f66 100644 --- a/panel/server/src/routes/economy.rs +++ b/panel/server/src/routes/economy.rs @@ -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)> { - 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)> { + 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> { +pub(crate) async fn finish_operation( + mut tx: sqlx::Transaction<'_, sqlx::Sqlite>, + server_id: i64, + operation_id: &str, + response: Value, +) -> AppResult> { 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, Json(p): Json) -> AppResult> { - 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, + Json(p): Json, +) -> AppResult> { + 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 = 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) -> AppResult>> { let rows: Vec<(i64, String, String, String, i32, f64, Option, Option)> = 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, -) -> AppResult>> { +pub async fn get_my_balances(auth: AuthUser, State(state): State) -> AppResult>> { 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, -) -> AppResult>> { +pub async fn get_my_transactions(auth: AuthUser, State(state): State) -> AppResult>> { 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, - State(state): State, -) -> AppResult>> { +pub async fn get_server_baltop(Path(server_id): Path, State(state): State) -> AppResult>> { 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, - State(state): State, -) -> AppResult>> { +pub async fn get_server_market(Path(server_id): Path, State(state): State) -> AppResult>> { let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String, Option)> = 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 { - 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 = 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 = 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 = 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 = 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, -) -> AppResult>> { +pub async fn server_baltop(GameServer(server): GameServer, State(state): State) -> AppResult>> { 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, Json(payload): Json, ) -> AppResult> { - 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, ) -> AppResult> { 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, Option)> = 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 } diff --git a/panel/server/src/routes/emails.rs b/panel/server/src/routes/emails.rs index 3df975b..a827d13 100644 --- a/panel/server/src/routes/emails.rs +++ b/panel/server/src/routes/emails.rs @@ -54,7 +54,8 @@ pub async fn templates(state: &AppState) -> AppResult> { 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 { 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)> = 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)> = + 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(>)), ("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(>)), + ("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}. Unsubscribe", esc(&vars["unsubscribe_url"])) + format!( + "You are receiving this because you have an account on {brand}. Unsubscribe", + 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> { 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) -> AppResult> { let conn = connections::settings(&state).await?; - let log: Vec<(i64, String, String, i64, i64, i64, Option, String, Option)> = - 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, Option)> = 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, Json(b): Json) -> AppResult> { 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::>(), "limit": MAX_RECIPIENTS }))) + Ok(Json( + json!({ "count": list.len(), "sample": list.iter().take(5).map(|p| p.username.clone()).collect::>(), "limit": MAX_RECIPIENTS }), + )) } #[derive(Deserialize)] @@ -362,11 +392,21 @@ pub async fn send(admin: AdminUser, State(state): State, 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::()).bind(label).bind(people.len() as i64).bind(crate::db::now()).fetch_one(&state.db).await?; + .bind(b.subject.chars().take(200).collect::()) + .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, 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::()).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::()) + .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, Query(q): Query) -> Html { - let page = |msg: &str| Html(format!("

{}

", esc(msg))); + let page = |msg: &str| { + Html(format!("

{}

", 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."); diff --git a/panel/server/src/routes/guild_bank.rs b/panel/server/src/routes/guild_bank.rs index 956397f..7570b5a 100644 --- a/panel/server/src/routes/guild_bank.rs +++ b/panel/server/src/routes/guild_bank.rs @@ -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 { - 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, Json(p): Json) -> AppResult> { +pub async fn server_bank_transfer( + GameServer(server): GameServer, + State(state): State, + Json(p): Json, +) -> AppResult> { 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, Json(p): Json) -> AppResult> { +pub async fn server_bank_credit( + GameServer(server): GameServer, + State(state): State, + Json(p): Json, +) -> AppResult> { 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::(); - 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 diff --git a/panel/server/src/routes/guilds.rs b/panel/server/src/routes/guilds.rs index 1556c09..d25946b 100644 --- a/panel/server/src/routes/guilds.rs +++ b/panel/server/src/routes/guilds.rs @@ -147,74 +147,152 @@ pub async fn guild_wallet( ) -> AppResult> { 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::>()}))) + .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::>()}), + )) } -pub async fn guild_wallet_deposit(auth: AuthUser, Path(guild_id): Path, State(state): State, Json(p): Json) -> AppResult> { +pub async fn guild_wallet_deposit( + auth: AuthUser, + Path(guild_id): Path, + State(state): State, + Json(p): Json, +) -> AppResult> { wallet_transfer(&state, &auth, &guild_id, p, false).await } -pub async fn guild_wallet_withdraw(auth: AuthUser, Path(guild_id): Path, State(state): State, Json(p): Json) -> AppResult> { +pub async fn guild_wallet_withdraw( + auth: AuthUser, + Path(guild_id): Path, + State(state): State, + Json(p): Json, +) -> AppResult> { 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> { +async fn wallet_transfer( + state: &AppState, + auth: &AuthUser, + guild_id: &str, + p: GuildWalletTransfer, + withdraw: bool, +) -> AppResult> { 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 = 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, State(state): State, Json(payload): Json) -> AppResult> { - let instance: Option = 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, + State(state): State, + Json(payload): Json, +) -> AppResult> { + let instance: Option = + 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::()).bind(crate::db::now()).execute(&state.db).await?; @@ -496,25 +590,51 @@ pub async fn request_join(auth: AuthUser, Path(id): Path, State(state): } pub async fn list_join_requests(auth: AuthUser, Path(id): Path, State(state): State) -> AppResult> { - 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::>()))) + 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::>()))) } #[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, Json(decision): Json) -> AppResult> { - 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, + Json(decision): Json, +) -> AppResult> { + 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 = 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 { let role: Option = 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 = 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 = 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, State(state): State) -> AppResult> { 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::>()))) } -pub async fn create_guild_role(auth: AuthUser, Path(id): Path, State(state): State, Json(role): Json) -> AppResult> { +pub async fn create_guild_role( + auth: AuthUser, + Path(id): Path, + State(state): State, + Json(role): Json, +) -> AppResult> { 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, Json(input): Json) -> AppResult> { +pub async fn assign_guild_role( + auth: AuthUser, + Path((id, uuid)): Path<(String, String)>, + State(state): State, + Json(input): Json, +) -> AppResult> { 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) -> AppResult> { +pub async fn delete_guild_role( + auth: AuthUser, + Path((id, role_id)): Path<(String, i64)>, + State(state): State, +) -> AppResult> { require_guild_leader(&state, &id, &auth.uuid).await?; let name: Option = 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, Json(payload): Json, ) -> AppResult> { - 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 ")); }; - 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); diff --git a/panel/server/src/routes/integrations.rs b/panel/server/src/routes/integrations.rs index f1e0b57..b6ffc67 100644 --- a/panel/server/src/routes/integrations.rs +++ b/panel/server/src/routes/integrations.rs @@ -61,12 +61,18 @@ pub struct GroupMapping { pub luckperms_group: String, } -pub async fn set_group_mapping(_: AdminUser, State(state): State, Path(id): Path, Json(m): Json) -> AppResult> { +pub async fn set_group_mapping( + _: AdminUser, + State(state): State, + Path(id): Path, + Json(m): Json, +) -> AppResult> { 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 { let settings = load_settings(conn).await?; let players: Vec = 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 = sqlx::query_scalar("SELECT id FROM users WHERE uuid = ? AND status = 'active'").bind(&uuid).fetch_optional(&mut *conn).await?; + let user_id: Option = + 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 = 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 = 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 = 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, Path(id): Path) -> AppResult> { 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 = rows .into_iter() .map(|(name, version, data, updated_at)| json!({ "name": name, "version": version, "data": serde_json::from_str::(&data).unwrap_or(Value::Null), "updated_at": updated_at })) @@ -297,12 +327,22 @@ pub async fn notify(conn: &mut SqliteConnection, server_id: Option, 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> { 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::(&payload).unwrap_or(Value::Null), "at": at })).collect()) } diff --git a/panel/server/src/routes/landing.rs b/panel/server/src/routes/landing.rs index 0f94110..8ccb631 100644 --- a/panel/server/src/routes/landing.rs +++ b/panel/server/src/routes/landing.rs @@ -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) -> AppResult("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) -> A Ok(Json(get_config(&state).await?)) } -pub async fn admin_put_landing(_: AdminUser, State(state): State, Json(cfg): Json) -> AppResult> { +pub async fn admin_put_landing( + _: AdminUser, + State(state): State, + Json(cfg): Json, +) -> AppResult> { 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, Json Ok(Json(cfg)) } -pub async fn upload_launcher( - _: AdminUser, - State(state): State, - mut form: Multipart, -) -> AppResult> { +pub async fn upload_launcher(_: AdminUser, State(state): State, mut form: Multipart) -> AppResult> { 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, - Path(platform): Path, -) -> AppResult { +pub async fn download_launcher(State(state): State, Path(platform): Path) -> AppResult { 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()); } } diff --git a/panel/server/src/routes/leveling.rs b/panel/server/src/routes/leveling.rs index df95a44..80c005e 100644 --- a/panel/server/src/routes/leveling.rs +++ b/panel/server/src/routes/leveling.rs @@ -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, 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 = 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, -) -> AppResult> { +pub async fn get_my_levels(auth: AuthUser, State(state): State) -> AppResult> { 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, - State(state): State, -) -> AppResult> { +pub async fn get_player_levels(Path(uuid): Path, State(state): State) -> AppResult> { get_user_levels_by_uuid(&state, &uuid).await.map(Json) } pub async fn get_user_levels_by_uuid(state: &AppState, uuid: &str) -> AppResult { - let row: Option<(i64, i64, Option, 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)> = + 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 = 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 = 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, -) -> AppResult>> { +pub async fn get_level_leaderboard(State(state): State) -> AppResult>> { let rows: Vec<(String, String, i64, i64, Option)> = 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, -) -> AppResult>> { +pub async fn list_rewards(State(state): State) -> AppResult>> { let rows: Vec<(i64, String, Option, 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) -> 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 = 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, - State(state): State, -) -> AppResult> { - 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, State(state): State) -> AppResult> { + 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 }))) } diff --git a/panel/server/src/routes/map.rs b/panel/server/src/routes/map.rs index edf1aa6..434f41b 100644 --- a/panel/server/src/routes/map.rs +++ b/panel/server/src/routes/map.rs @@ -22,12 +22,25 @@ fn ago(secs: i64) -> String { } async fn online_on(conn: &mut SqliteConnection, server_id: i64, uuid: &str) -> AppResult { - 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::().split_whitespace().collect::>().join(" ").chars().take(max).collect() + text.chars() + .filter(|c| c.is_whitespace() || !c.is_control()) + .map(|c| if c.is_whitespace() { ' ' } else { c }) + .collect::() + .split_whitespace() + .collect::>() + .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, State(state): State, Json(a): Json) -> AppResult> { +pub async fn map_action( + auth: AuthUser, + Path(server_id): Path, + State(state): State, + Json(a): Json, +) -> AppResult> { 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, 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 AppResult<()> { - let servers: Vec = sqlx::query_scalar("SELECT server_id FROM server_online WHERE uuid = ?").bind(to_uuid).fetch_all(&mut *conn).await?; + let servers: Vec = + 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 { +pub async fn create_invite( + state: &AppState, + inviter_uuid: &str, + inviter_name: &str, + guild_id: &str, + target_uuid: &str, +) -> AppResult { 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 = 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 = 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 = sqlx::query_scalar("SELECT username FROM users WHERE uuid = ? AND status = 'active'").bind(target_uuid).fetch_optional(&mut *conn).await?; + let target: Option = 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, State(state): State, Json(p): Json) -> AppResult> { +pub async fn send_invite( + auth: AuthUser, + Path(guild_id): Path, + State(state): State, + Json(p): Json, +) -> AppResult> { 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) -> AppRes pub async fn respond(state: &AppState, uuid: &str, name: &str, invite: Option, tag: Option<&str>, accept: bool) -> AppResult { 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, Json(p): Json) -> AppResult> { - let inviter: Option = 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, + Json(p): Json, +) -> AppResult> { + let inviter: Option = + 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 = sqlx::query_scalar("SELECT uuid FROM users WHERE username = ? COLLATE NOCASE").bind(p.target.trim()).fetch_optional(&state.db).await?; + let target: Option = 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 = 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, Json(p): Json) -> AppResult> { +pub async fn server_invites( + GameServer(_): GameServer, + State(state): State, + Json(p): Json, +) -> AppResult> { Ok(Json(json!({ "invites": pending_for(&state, &p.uuid).await? }))) } -pub async fn server_invite_respond(GameServer(_): GameServer, State(state): State, Json(p): Json) -> AppResult> { - let name: Option = 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, + Json(p): Json, +) -> AppResult> { + let name: Option = + 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?)) diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index 7972340..08d4659 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -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; diff --git a/panel/server/src/routes/quests.rs b/panel/server/src/routes/quests.rs index f12b88f..4f26a7a 100644 --- a/panel/server/src/routes/quests.rs +++ b/panel/server/src/routes/quests.rs @@ -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> = 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, -) -> AppResult>> { +pub async fn get_my_quests(auth: AuthUser, State(state): State) -> AppResult>> { 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, - State(state): State, -) -> AppResult> { +pub async fn claim_quest(auth: AuthUser, Path(quest_id): Path, State(state): State) -> AppResult> { 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, -) -> AppResult>> { - 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) -> AppResult>> { + 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> { 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, - State(state): State, -) -> AppResult> { +pub async fn admin_delete_quest(_admin: AdminUser, Path(id): Path, State(state): State) -> AppResult> { 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 }))) } diff --git a/panel/server/src/routes/rewards.rs b/panel/server/src/routes/rewards.rs index 80e3ed9..fa4f132 100644 --- a/panel/server/src/routes/rewards.rs +++ b/panel/server/src/routes/rewards.rs @@ -22,7 +22,12 @@ pub struct BundleBody { actions: Value, } -pub async fn admin_put(_: AdminUser, State(state): State, Path((kind, id)): Path<(String, String)>, Json(body): Json) -> AppResult> { +pub async fn admin_put( + _: AdminUser, + State(state): State, + Path((kind, id)): Path<(String, String)>, + Json(body): Json, +) -> AppResult> { 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, 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) -> AppResult> { - 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 = serde_json::from_str(&actions).unwrap_or_default(); diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index 2544796..a8f26af 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -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, - req_ip: Option<&str>, - since: &str, -) -> AppResult { +async fn verify_launcher_ip(state: &AppState, user_id: Option, req_ip: Option<&str>, since: &str) -> AppResult { 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, 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, .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, // 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 = 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 = + 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 = sqlx::query_scalar("SELECT global_level FROM user_levels WHERE uuid = ?") - .bind(uuid) - .fetch_optional(&mut **tx) - .await?; + let global_level: Option = + 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 = 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 = + 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, Path( pub async fn remove(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { // 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 = 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 = 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) -> AppResult> { let servers: Vec = sqlx::query_as("SELECT * FROM game_servers ORDER BY name COLLATE NOCASE").fetch_all(&state.db).await?; - let list: Vec = 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 = 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, - Query(q): Query, -) -> AppResult> { +pub async fn global_leaderboard(State(state): State, Query(q): Query) -> AppResult> { 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) = - 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, - AuthUser(user): AuthUser, -) -> AppResult> { +pub async fn account_player_stats(State(state): State, AuthUser(user): AuthUser) -> AppResult> { let rows: Vec = 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 = - 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 = 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, diff --git a/panel/server/src/routes/social.rs b/panel/server/src/routes/social.rs index c3947e1..514b86d 100644 --- a/panel/server/src/routes/social.rs +++ b/panel/server/src/routes/social.rs @@ -13,10 +13,7 @@ use serde_json::Value; // Friends System // --------------------------------------------------------------------------- -pub async fn list_friends( - auth: AuthUser, - State(state): State, -) -> AppResult>> { +pub async fn list_friends(auth: AuthUser, State(state): State) -> AppResult>> { let rows: Vec<(String, String, String, String, bool, Option)> = 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, - State(state): State, -) -> AppResult> { +pub async fn remove_friend(auth: AuthUser, Path(target_uuid): Path, State(state): State) -> AppResult> { 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, Json(payload): Json, ) -> AppResult> { - 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, -) -> AppResult>> { - let rows: Vec<( - String, - String, - String, - String, - String, - String, - Option, - Option, - String, - String, - String, - )> = sqlx::query_as( +pub async fn list_my_game_invites(auth: AuthUser, State(state): State) -> AppResult>> { + let rows: Vec<(String, String, String, String, String, String, Option, Option, 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, Json(payload): Json, ) -> AppResult> { - 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, State(state): State, ) -> AppResult> { - 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, Option, Option)> = 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, Option, Option)> = + 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, 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 = 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 { 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 = sqlx::query_scalar("SELECT MAX(last_seen) FROM player_stats WHERE uuid = ?").bind(uuid).fetch_one(db).await?; + let last_seen: Option = + 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::() }), - _ => format!("Earned {}", if detail.is_empty() { "an advancement".into() } else { detail.chars().take(60).collect::() }), + "kill" => { + format!("Defeated {}", if detail.is_empty() { "a foe".into() } else { detail.chars().take(40).collect::() }) + } + _ => format!( + "Earned {}", + if detail.is_empty() { "an advancement".into() } else { detail.chars().take(60).collect::() } + ), }; 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 = sqlx::query_scalar("SELECT accent_color FROM user_profiles WHERE uuid = ?").bind(uuid).fetch_optional(db).await?.flatten(); + let accent_color: Option = + 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, - State(state): State, -) -> AppResult> { - 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, State(state): State) -> AppResult> { + 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, - State(state): State, -) -> AppResult> { +pub async fn like_user_post(auth: AuthUser, Path(post_id): Path, State(state): State) -> AppResult> { 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, Option, Option, i64, Option, bool, Option, Option)> = - sqlx::query_as( - "SELECT u.uuid, u.username, u.role, u.status, u.skin_hash, + let rows: Vec<( + String, + String, + String, + String, + Option, + Option, + Option, + i64, + Option, + bool, + Option, + Option, + )> = 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() diff --git a/panel/server/src/routes/worldmap.rs b/panel/server/src/routes/worldmap.rs index f4b2a23..04920cf 100644 --- a/panel/server/src/routes/worldmap.rs +++ b/panel/server/src/routes/worldmap.rs @@ -42,13 +42,20 @@ pub struct DimQuery { dim: String, } -pub async fn game_tiles(GameServer(server): GameServer, State(state): State, Query(q): Query, body: Bytes) -> AppResult> { +pub async fn game_tiles( + GameServer(server): GameServer, + State(state): State, + Query(q): Query, + body: Bytes, +) -> AppResult> { 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, } -pub async fn game_players(GameServer(server): GameServer, State(state): State, Json(b): Json) -> AppResult> { +pub async fn game_players( + GameServer(server): GameServer, + State(state): State, + Json(b): Json, +) -> AppResult> { 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, Json(overlay): Json) -> AppResult> { +pub async fn game_overlay( + GameServer(server): GameServer, + State(state): State, + Json(overlay): Json, +) -> AppResult> { 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, Path(id): pub async fn admin_reset(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { 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=`. The token comes from [`viewer_info`], so only signed-in /// players can load tiles, yet plain `` tags work. -pub async fn tile(State(state): State, Path((id, dim, z, x, file)): Path<(i64, String, u8, i32, String)>, Query(q): Query, headers: axum::http::HeaderMap) -> Response { +pub async fn tile( + State(state): State, + Path((id, dim, z, x, file)): Path<(i64, String, u8, i32, String)>, + Query(q): Query, + 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) }))) } diff --git a/panel/server/src/seed.rs b/panel/server/src/seed.rs index 074a2ba..3213943 100644 --- a/panel/server/src/seed.rs +++ b/panel/server/src/seed.rs @@ -84,7 +84,10 @@ const SPECIFIC_QUEST_TARGETS: &[(&str, &str)] = &[ pub async fn upgrade_seeded_quest_targets(pool: &SqlitePool) -> Result<()> { for (id, target) in SPECIFIC_QUEST_TARGETS { sqlx::query("UPDATE quests SET target_stat = ? WHERE id = ? AND target_stat IN ('blocks_broken', 'blocks_placed', 'mob_kills')") - .bind(target).bind(id).execute(pool).await?; + .bind(target) + .bind(id) + .execute(pool) + .await?; } Ok(()) } @@ -119,110 +122,651 @@ async fn seed_quests(pool: &SqlitePool) -> Result<()> { ("q_d_mine_stone", "Stonebreaker", "Break 64 stone or cobblestone blocks", "daily", "mining", "blocks_broken", 64, 120, "pickaxe"), ("q_d_mine_iron", "Iron Prospector", "Mine 16 iron ore blocks underground", "daily", "mining", "blocks_broken", 16, 150, "pickaxe"), ("q_d_mine_copper", "Copper Collector", "Gather 24 copper ore blocks", "daily", "mining", "blocks_broken", 24, 130, "pickaxe"), - ("q_d_mine_gold", "Gold Rush", "Mine 8 gold ore blocks in caves or the Nether", "daily", "mining", "blocks_broken", 8, 180, "pickaxe"), + ( + "q_d_mine_gold", + "Gold Rush", + "Mine 8 gold ore blocks in caves or the Nether", + "daily", + "mining", + "blocks_broken", + 8, + 180, + "pickaxe", + ), ("q_d_mine_diamond", "Diamond Digger", "Mine 4 rare diamond ore blocks", "daily", "mining", "blocks_broken", 4, 300, "gem"), ("q_d_mine_redstone", "Redstone Engineer", "Mine 12 redstone ore blocks", "daily", "mining", "blocks_broken", 12, 160, "zap"), - ("q_d_mine_deepslate", "Deepslate Delver", "Mine 48 deepslate blocks in the depths", "daily", "mining", "blocks_broken", 48, 140, "pickaxe"), - ("q_d_mine_quartz", "Nether Prospecting", "Mine 16 Nether quartz ore blocks", "daily", "mining", "blocks_broken", 16, 180, "sparkles"), - ("q_d_mine_debris", "Debris Hunter", "Mine 2 Ancient Debris blocks in the Nether", "daily", "mining", "blocks_broken", 2, 450, "shield"), + ( + "q_d_mine_deepslate", + "Deepslate Delver", + "Mine 48 deepslate blocks in the depths", + "daily", + "mining", + "blocks_broken", + 48, + 140, + "pickaxe", + ), + ( + "q_d_mine_quartz", + "Nether Prospecting", + "Mine 16 Nether quartz ore blocks", + "daily", + "mining", + "blocks_broken", + 16, + 180, + "sparkles", + ), + ( + "q_d_mine_debris", + "Debris Hunter", + "Mine 2 Ancient Debris blocks in the Nether", + "daily", + "mining", + "blocks_broken", + 2, + 450, + "shield", + ), ("q_d_mine_sand", "Dune Excavator", "Dig 32 sand or gravel blocks", "daily", "mining", "blocks_broken", 32, 90, "layers"), ("q_d_mine_obsidian", "Obsidian Forger", "Mine 8 obsidian blocks", "daily", "mining", "blocks_broken", 8, 250, "shield"), - // Daily Combat ("q_d_mob_zombies", "Zombie Purge", "Defeat 8 zombies wandering in the dark", "daily", "combat", "mob_kills", 8, 120, "swords"), ("q_d_mob_skeletons", "Bone Collector", "Take down 6 skeleton archers", "daily", "combat", "mob_kills", 6, 130, "crosshair"), - ("q_d_mob_spiders", "Arachnid Slayer", "Defeat 5 creepy spiders or cave spiders", "daily", "combat", "mob_kills", 5, 120, "shield-alert"), + ( + "q_d_mob_spiders", + "Arachnid Slayer", + "Defeat 5 creepy spiders or cave spiders", + "daily", + "combat", + "mob_kills", + 5, + 120, + "shield-alert", + ), ("q_d_mob_creepers", "Bomb Squad", "Eliminate 4 creepers before they explode", "daily", "combat", "mob_kills", 4, 180, "flame"), ("q_d_mob_endermen", "Ender Stalker", "Defeat 2 mysterious Endermen", "daily", "combat", "mob_kills", 2, 220, "eye"), - ("q_d_mob_witches", "Witch Trial", "Defeat 1 witch brewing dangerous potions", "daily", "combat", "mob_kills", 1, 200, "flask-conical"), + ( + "q_d_mob_witches", + "Witch Trial", + "Defeat 1 witch brewing dangerous potions", + "daily", + "combat", + "mob_kills", + 1, + 200, + "flask-conical", + ), ("q_d_mob_slimes", "Slime Sweeper", "Defeat 6 bouncy slimes or magma cubes", "daily", "combat", "mob_kills", 6, 140, "sparkles"), - ("q_d_mob_piglins", "Piglin Skirmish", "Defeat 6 hostile piglins or zombified piglins", "daily", "combat", "mob_kills", 6, 160, "swords"), + ( + "q_d_mob_piglins", + "Piglin Skirmish", + "Defeat 6 hostile piglins or zombified piglins", + "daily", + "combat", + "mob_kills", + 6, + 160, + "swords", + ), ("q_d_mob_blazes", "Firestarter", "Defeat 4 blazing fortress defenders", "daily", "combat", "mob_kills", 4, 250, "flame"), - ("q_d_mob_drowned", "Deep Water Danger", "Defeat 5 drowned lurkers in rivers or oceans", "daily", "combat", "mob_kills", 5, 140, "droplets"), + ( + "q_d_mob_drowned", + "Deep Water Danger", + "Defeat 5 drowned lurkers in rivers or oceans", + "daily", + "combat", + "mob_kills", + 5, + 140, + "droplets", + ), ("q_d_mob_monsters", "Night Watch", "Defeat any 15 mobs", "daily", "combat", "mob_kills", 15, 200, "swords"), - ("q_d_pvp_champion", "Gladiator's Glory", "Defeat 1 player in honorable combat", "daily", "combat", "player_kills", 1, 250, "trophy"), - + ( + "q_d_pvp_champion", + "Gladiator's Glory", + "Defeat 1 player in honorable combat", + "daily", + "combat", + "player_kills", + 1, + 250, + "trophy", + ), // Daily Building - ("q_d_build_planks", "Woodworker", "Place 48 wooden planks in any construction", "daily", "building", "blocks_placed", 48, 120, "hammer"), - ("q_d_build_bricks", "Mason's Pride", "Place 32 stone bricks or polished stones", "daily", "building", "blocks_placed", 32, 130, "box"), + ( + "q_d_build_planks", + "Woodworker", + "Place 48 wooden planks in any construction", + "daily", + "building", + "blocks_placed", + 48, + 120, + "hammer", + ), + ( + "q_d_build_bricks", + "Mason's Pride", + "Place 32 stone bricks or polished stones", + "daily", + "building", + "blocks_placed", + 32, + 130, + "box", + ), ("q_d_build_glass", "Window Pane", "Place 16 glass blocks or panes", "daily", "building", "blocks_placed", 16, 100, "eye"), - ("q_d_build_torches", "Let There Be Light", "Place 20 torches to illuminate the area", "daily", "building", "blocks_placed", 20, 90, "flame"), - ("q_d_build_lanterns", "Cozy Hearth", "Place 6 lanterns around your settlement", "daily", "building", "blocks_placed", 6, 120, "sparkles"), + ( + "q_d_build_torches", + "Let There Be Light", + "Place 20 torches to illuminate the area", + "daily", + "building", + "blocks_placed", + 20, + 90, + "flame", + ), + ( + "q_d_build_lanterns", + "Cozy Hearth", + "Place 6 lanterns around your settlement", + "daily", + "building", + "blocks_placed", + 6, + 120, + "sparkles", + ), ("q_d_build_slabs", "Tiered Architecture", "Place 24 slabs or stairs", "daily", "building", "blocks_placed", 24, 110, "layers"), ("q_d_build_roof", "Under Cover", "Place 50 blocks of any kind", "daily", "building", "blocks_placed", 50, 140, "home"), - ("q_d_build_walls", "Fortified Walls", "Place 30 cobblestone or deepslate walls", "daily", "building", "blocks_placed", 30, 130, "shield"), - ("q_d_build_general", "Master Builder", "Place 100 blocks of any material", "daily", "building", "blocks_placed", 100, 180, "hammer"), + ( + "q_d_build_walls", + "Fortified Walls", + "Place 30 cobblestone or deepslate walls", + "daily", + "building", + "blocks_placed", + 30, + 130, + "shield", + ), + ( + "q_d_build_general", + "Master Builder", + "Place 100 blocks of any material", + "daily", + "building", + "blocks_placed", + 100, + 180, + "hammer", + ), ("q_d_build_monument", "Grand Monument", "Place 150 blocks of any kind", "daily", "building", "blocks_placed", 150, 220, "castle"), - // Daily Farming & Harvesting ("q_d_farm_wood", "Lumberjack", "Chop down 32 wood logs", "daily", "farming", "blocks_broken", 32, 120, "axe"), ("q_d_farm_birch", "Birch Forester", "Harvest 20 birch or spruce logs", "daily", "farming", "blocks_broken", 20, 110, "axe"), ("q_d_farm_jungle", "Jungle Explorer", "Chop 24 jungle or acacia logs", "daily", "farming", "blocks_broken", 24, 130, "axe"), ("q_d_farm_wheat", "Wheat Harvester", "Harvest 24 ripe wheat stalks", "daily", "farming", "blocks_broken", 24, 100, "sprout"), - ("q_d_farm_potatoes", "Potato Bounty", "Gather 24 potatoes from the fields", "daily", "farming", "blocks_broken", 24, 100, "sprout"), + ( + "q_d_farm_potatoes", + "Potato Bounty", + "Gather 24 potatoes from the fields", + "daily", + "farming", + "blocks_broken", + 24, + 100, + "sprout", + ), ("q_d_farm_carrots", "Golden Nutrition", "Harvest 24 fresh carrots", "daily", "farming", "blocks_broken", 24, 100, "sprout"), ("q_d_farm_sugarcane", "Sugar Rush", "Cut 16 sugar cane reeds", "daily", "farming", "blocks_broken", 16, 90, "sprout"), ("q_d_farm_pumpkins", "Patch Patrol", "Harvest 8 pumpkins or melons", "daily", "farming", "blocks_broken", 8, 110, "apple"), ("q_d_farm_flowers", "Botanist", "Collect 12 wild flowers", "daily", "farming", "blocks_broken", 12, 80, "flower"), ("q_d_farm_leaves", "Foliage Trimmer", "Break 40 leaf blocks", "daily", "farming", "blocks_broken", 40, 80, "scissors"), - // Daily Social & Playtime ("q_d_play_15m", "Quick Check-in", "Play on any server for 15 minutes", "daily", "social", "playtime_secs", 900, 100, "clock"), ("q_d_play_30m", "Dedicated Citizen", "Spend 30 minutes in game", "daily", "social", "playtime_secs", 1800, 180, "clock"), ("q_d_play_60m", "Hour of Power", "Play for a total of one hour", "daily", "social", "playtime_secs", 3600, 250, "clock"), ("q_d_chat_chatty", "Good Morning", "Send 5 chat messages", "daily", "social", "messages", 5, 80, "message-square"), - ("q_d_chat_talkative", "Community Voice", "Send 15 chat messages participating in chat", "daily", "social", "messages", 15, 120, "message-square"), + ( + "q_d_chat_talkative", + "Community Voice", + "Send 15 chat messages participating in chat", + "daily", + "social", + "messages", + 15, + 120, + "message-square", + ), ("q_d_join_again", "Frequent Flyer", "Join a server 2 distinct times today", "daily", "social", "joins", 2, 80, "door-open"), - ("q_d_explore_depths", "Into the Deep", "Mine 30 tuff or deepslate tiles", "daily", "exploration", "blocks_broken", 30, 110, "compass"), + ( + "q_d_explore_depths", + "Into the Deep", + "Mine 30 tuff or deepslate tiles", + "daily", + "exploration", + "blocks_broken", + 30, + 110, + "compass", + ), ("q_d_explore_caves", "Spelunker", "Break 50 blocks of any kind", "daily", "exploration", "blocks_broken", 50, 130, "compass"), - ("q_d_explore_diorite", "Geologist", "Collect 20 granite, diorite, or andesite blocks", "daily", "exploration", "blocks_broken", 20, 100, "mountain"), - ("q_d_explore_nether", "Nether Expedition", "Mine 40 nether blocks while surviving", "daily", "exploration", "blocks_broken", 40, 160, "flame"), - ("q_d_explore_clay", "Clay Digger", "Excavate 16 clay blocks from river beds", "daily", "exploration", "blocks_broken", 16, 110, "droplets"), - ("q_d_explore_snow", "Winter Wonderland", "Shovel 32 snow or ice blocks", "daily", "exploration", "blocks_broken", 32, 90, "snowflake"), - ("q_d_explore_magma", "Volcanic Venture", "Mine 8 magma blocks or basalt", "daily", "exploration", "blocks_broken", 8, 140, "flame"), - ("q_d_build_decor", "Interior Decorator", "Place 15 decorative items or carpets", "daily", "building", "blocks_placed", 15, 90, "palette"), - + ( + "q_d_explore_diorite", + "Geologist", + "Collect 20 granite, diorite, or andesite blocks", + "daily", + "exploration", + "blocks_broken", + 20, + 100, + "mountain", + ), + ( + "q_d_explore_nether", + "Nether Expedition", + "Mine 40 nether blocks while surviving", + "daily", + "exploration", + "blocks_broken", + 40, + 160, + "flame", + ), + ( + "q_d_explore_clay", + "Clay Digger", + "Excavate 16 clay blocks from river beds", + "daily", + "exploration", + "blocks_broken", + 16, + 110, + "droplets", + ), + ( + "q_d_explore_snow", + "Winter Wonderland", + "Shovel 32 snow or ice blocks", + "daily", + "exploration", + "blocks_broken", + 32, + 90, + "snowflake", + ), + ( + "q_d_explore_magma", + "Volcanic Venture", + "Mine 8 magma blocks or basalt", + "daily", + "exploration", + "blocks_broken", + 8, + 140, + "flame", + ), + ( + "q_d_build_decor", + "Interior Decorator", + "Place 15 decorative items or carpets", + "daily", + "building", + "blocks_placed", + 15, + 90, + "palette", + ), // Weekly Quests (Higher targets & XP rewards) - ("q_w_mine_deepslate_grand", "Excavation Giant", "Mine 500 deepslate blocks in the darkest depths", "weekly", "mining", "blocks_broken", 500, 750, "pickaxe"), - ("q_w_mine_iron_hoard", "Iron Hoarder", "Mine 128 iron ore across the week", "weekly", "mining", "blocks_broken", 128, 850, "pickaxe"), - ("q_w_mine_diamonds_grand", "Diamond Empire", "Discover and mine 32 diamonds", "weekly", "mining", "blocks_broken", 32, 1200, "gem"), - ("q_w_mine_gold_grand", "Midas Touch", "Mine 64 gold ore from caves or fortresses", "weekly", "mining", "blocks_broken", 64, 900, "award"), - ("q_w_mine_redstone_grand", "Automation Maestro", "Mine 128 redstone ore blocks", "weekly", "mining", "blocks_broken", 128, 800, "zap"), - ("q_w_mine_debris_grand", "Netherite Titan", "Locate and extract 12 Ancient Debris blocks", "weekly", "mining", "blocks_broken", 12, 1600, "shield"), - ("q_w_mine_nether_grand", "Underworld Quarry", "Break 300 blocks in the Nether dimension", "weekly", "mining", "blocks_broken", 300, 700, "flame"), - ("q_w_mine_stone_grand", "Mountain Mover", "Break 1,000 stone or cobblestone blocks", "weekly", "mining", "blocks_broken", 1000, 1100, "mountain"), - + ( + "q_w_mine_deepslate_grand", + "Excavation Giant", + "Mine 500 deepslate blocks in the darkest depths", + "weekly", + "mining", + "blocks_broken", + 500, + 750, + "pickaxe", + ), + ( + "q_w_mine_iron_hoard", + "Iron Hoarder", + "Mine 128 iron ore across the week", + "weekly", + "mining", + "blocks_broken", + 128, + 850, + "pickaxe", + ), + ( + "q_w_mine_diamonds_grand", + "Diamond Empire", + "Discover and mine 32 diamonds", + "weekly", + "mining", + "blocks_broken", + 32, + 1200, + "gem", + ), + ( + "q_w_mine_gold_grand", + "Midas Touch", + "Mine 64 gold ore from caves or fortresses", + "weekly", + "mining", + "blocks_broken", + 64, + 900, + "award", + ), + ( + "q_w_mine_redstone_grand", + "Automation Maestro", + "Mine 128 redstone ore blocks", + "weekly", + "mining", + "blocks_broken", + 128, + 800, + "zap", + ), + ( + "q_w_mine_debris_grand", + "Netherite Titan", + "Locate and extract 12 Ancient Debris blocks", + "weekly", + "mining", + "blocks_broken", + 12, + 1600, + "shield", + ), + ( + "q_w_mine_nether_grand", + "Underworld Quarry", + "Break 300 blocks in the Nether dimension", + "weekly", + "mining", + "blocks_broken", + 300, + 700, + "flame", + ), + ( + "q_w_mine_stone_grand", + "Mountain Mover", + "Break 1,000 stone or cobblestone blocks", + "weekly", + "mining", + "blocks_broken", + 1000, + 1100, + "mountain", + ), ("q_w_mob_purge_grand", "Monster Exterminator", "Defeat 100 mobs", "weekly", "combat", "mob_kills", 100, 1000, "swords"), - ("q_w_mob_skeletons_grand", "Archer's Nemesis", "Defeat 40 skeleton sharpshooters", "weekly", "combat", "mob_kills", 40, 750, "crosshair"), - ("q_w_mob_creepers_grand", "Demolition Defuser", "Take down 30 dangerous creepers", "weekly", "combat", "mob_kills", 30, 850, "flame"), + ( + "q_w_mob_skeletons_grand", + "Archer's Nemesis", + "Defeat 40 skeleton sharpshooters", + "weekly", + "combat", + "mob_kills", + 40, + 750, + "crosshair", + ), + ( + "q_w_mob_creepers_grand", + "Demolition Defuser", + "Take down 30 dangerous creepers", + "weekly", + "combat", + "mob_kills", + 30, + 850, + "flame", + ), ("q_w_mob_endermen_grand", "Void Hunter", "Defeat 15 elusive Endermen", "weekly", "combat", "mob_kills", 15, 1100, "eye"), - ("q_w_mob_blazes_grand", "Fortress Conqueror", "Extinguish 25 fiery blazes in Nether fortresses", "weekly", "combat", "mob_kills", 25, 1200, "flame"), + ( + "q_w_mob_blazes_grand", + "Fortress Conqueror", + "Extinguish 25 fiery blazes in Nether fortresses", + "weekly", + "combat", + "mob_kills", + 25, + 1200, + "flame", + ), ("q_w_mob_withers_grand", "Skull Seeker", "Defeat 12 wither skeletons", "weekly", "combat", "mob_kills", 12, 1300, "skull"), - ("q_w_pvp_champion_grand", "Arena Legend", "Defeat 10 players in competitive PvP matches", "weekly", "combat", "player_kills", 10, 1500, "trophy"), - ("q_w_pvp_master_grand", "Warlord of SCOPENET", "Claim 25 PvP victories across all servers", "weekly", "combat", "player_kills", 25, 2500, "crown"), - - ("q_w_build_castle", "Castle Founder", "Place 800 stone bricks or fortified blocks", "weekly", "building", "blocks_placed", 800, 1000, "castle"), - ("q_w_build_planks_grand", "Town Carpenter", "Place 500 wooden planks on structures", "weekly", "building", "blocks_placed", 500, 800, "hammer"), + ( + "q_w_pvp_champion_grand", + "Arena Legend", + "Defeat 10 players in competitive PvP matches", + "weekly", + "combat", + "player_kills", + 10, + 1500, + "trophy", + ), + ( + "q_w_pvp_master_grand", + "Warlord of SCOPENET", + "Claim 25 PvP victories across all servers", + "weekly", + "combat", + "player_kills", + 25, + 2500, + "crown", + ), + ( + "q_w_build_castle", + "Castle Founder", + "Place 800 stone bricks or fortified blocks", + "weekly", + "building", + "blocks_placed", + 800, + 1000, + "castle", + ), + ( + "q_w_build_planks_grand", + "Town Carpenter", + "Place 500 wooden planks on structures", + "weekly", + "building", + "blocks_placed", + 500, + 800, + "hammer", + ), ("q_w_build_glass_grand", "Sky Dome", "Place 150 glass blocks", "weekly", "building", "blocks_placed", 150, 700, "eye"), - ("q_w_build_lanterns_grand", "Beacon of Light", "Place 40 lanterns or glowstone blocks", "weekly", "building", "blocks_placed", 40, 750, "sparkles"), - ("q_w_build_grand_city", "Metropolis Architect", "Place 1,500 total blocks across any build", "weekly", "building", "blocks_placed", 1500, 1400, "hammer"), - ("q_w_build_enclosure", "Fortress Perimeters", "Place 150 fence posts or wall segments", "weekly", "building", "blocks_placed", 150, 700, "shield"), - - ("q_w_farm_logs_grand", "Deforestation Duty", "Harvest 250 wood logs of any species", "weekly", "farming", "blocks_broken", 250, 800, "axe"), - ("q_w_farm_crops_grand", "Granary Overflow", "Harvest 200 total food crops", "weekly", "farming", "blocks_broken", 200, 750, "sprout"), - ("q_w_farm_reeds_grand", "Sugar Empire", "Harvest 100 sugar cane or bamboo", "weekly", "farming", "blocks_broken", 100, 650, "sprout"), - ("q_w_farm_pumpkins_grand", "Autumn Festival", "Harvest 40 pumpkins or melons", "weekly", "farming", "blocks_broken", 40, 700, "apple"), - - ("q_w_play_3h", "Weekly Adventurer", "Spend 3 hours playing on SCOPENET servers", "weekly", "social", "playtime_secs", 10800, 800, "clock"), - ("q_w_play_8h", "Dedicated Veteran", "Spend 8 hours playing across the week", "weekly", "social", "playtime_secs", 28800, 1500, "clock"), - ("q_w_play_15h", "No-Lifer Supremacy", "Clock in 15 hours of active gameplay", "weekly", "social", "playtime_secs", 54000, 2500, "zap"), - ("q_w_chat_social", "Town Hall Speaker", "Contribute 50 chat messages across community servers", "weekly", "social", "messages", 50, 600, "message-square"), + ( + "q_w_build_lanterns_grand", + "Beacon of Light", + "Place 40 lanterns or glowstone blocks", + "weekly", + "building", + "blocks_placed", + 40, + 750, + "sparkles", + ), + ( + "q_w_build_grand_city", + "Metropolis Architect", + "Place 1,500 total blocks across any build", + "weekly", + "building", + "blocks_placed", + 1500, + 1400, + "hammer", + ), + ( + "q_w_build_enclosure", + "Fortress Perimeters", + "Place 150 fence posts or wall segments", + "weekly", + "building", + "blocks_placed", + 150, + 700, + "shield", + ), + ( + "q_w_farm_logs_grand", + "Deforestation Duty", + "Harvest 250 wood logs of any species", + "weekly", + "farming", + "blocks_broken", + 250, + 800, + "axe", + ), + ( + "q_w_farm_crops_grand", + "Granary Overflow", + "Harvest 200 total food crops", + "weekly", + "farming", + "blocks_broken", + 200, + 750, + "sprout", + ), + ( + "q_w_farm_reeds_grand", + "Sugar Empire", + "Harvest 100 sugar cane or bamboo", + "weekly", + "farming", + "blocks_broken", + 100, + 650, + "sprout", + ), + ( + "q_w_farm_pumpkins_grand", + "Autumn Festival", + "Harvest 40 pumpkins or melons", + "weekly", + "farming", + "blocks_broken", + 40, + 700, + "apple", + ), + ( + "q_w_play_3h", + "Weekly Adventurer", + "Spend 3 hours playing on SCOPENET servers", + "weekly", + "social", + "playtime_secs", + 10800, + 800, + "clock", + ), + ( + "q_w_play_8h", + "Dedicated Veteran", + "Spend 8 hours playing across the week", + "weekly", + "social", + "playtime_secs", + 28800, + 1500, + "clock", + ), + ( + "q_w_play_15h", + "No-Lifer Supremacy", + "Clock in 15 hours of active gameplay", + "weekly", + "social", + "playtime_secs", + 54000, + 2500, + "zap", + ), + ( + "q_w_chat_social", + "Town Hall Speaker", + "Contribute 50 chat messages across community servers", + "weekly", + "social", + "messages", + 50, + 600, + "message-square", + ), ("q_w_joins_loyal", "Daily Habits", "Join game servers on at least 5 occasions", "weekly", "social", "joins", 5, 700, "door-open"), - ("q_w_explore_caves_grand", "Underground Cartographer", "Break 400 blocks of any kind", "weekly", "exploration", "blocks_broken", 400, 850, "compass"), - ("q_w_explore_nether_grand2", "Infernal Pioneer", "Travel and mine 250 blocks in the Nether", "weekly", "exploration", "blocks_broken", 250, 800, "flame"), - ("q_w_explore_ocean", "Abyssal Diver", "Break 100 prismarine, gravel, or coral blocks", "weekly", "exploration", "blocks_broken", 100, 750, "droplets"), - ("q_w_explore_sand_grand", "Desert Terraformer", "Dig 300 sand or red sand blocks", "weekly", "exploration", "blocks_broken", 300, 650, "layers"), - ("q_w_build_complex", "Civil Engineer", "Place 600 stone bricks or polished granite", "weekly", "building", "blocks_placed", 600, 950, "box"), + ( + "q_w_explore_caves_grand", + "Underground Cartographer", + "Break 400 blocks of any kind", + "weekly", + "exploration", + "blocks_broken", + 400, + 850, + "compass", + ), + ( + "q_w_explore_nether_grand2", + "Infernal Pioneer", + "Travel and mine 250 blocks in the Nether", + "weekly", + "exploration", + "blocks_broken", + 250, + 800, + "flame", + ), + ( + "q_w_explore_ocean", + "Abyssal Diver", + "Break 100 prismarine, gravel, or coral blocks", + "weekly", + "exploration", + "blocks_broken", + 100, + 750, + "droplets", + ), + ( + "q_w_explore_sand_grand", + "Desert Terraformer", + "Dig 300 sand or red sand blocks", + "weekly", + "exploration", + "blocks_broken", + 300, + 650, + "layers", + ), + ( + "q_w_build_complex", + "Civil Engineer", + "Place 600 stone bricks or polished granite", + "weekly", + "building", + "blocks_placed", + 600, + 950, + "box", + ), ]; for (id, title, desc, period, cat, stat, target, xp, icon) in raw_quests { @@ -251,28 +795,336 @@ async fn seed_achievements(pool: &SqlitePool) -> Result<()> { let now = chrono::Utc::now().to_rfc3339(); let raw_achievements = [ - ("ach_first_steps", "First Steps", "Join your first SCOPENET server and begin your journey.", "progression", "task", "wooden_pickaxe", "stone", "bronze", "stat", "joins", 1, 100, 0), - ("ach_miner_49er", "Strike It Rich!", "Mine your first diamond ore underground.", "mining", "task", "diamond", "deepslate", "gold", "stat", "blocks_broken", 50, 250, 0), - ("ach_first_blood", "First Blood", "Defeat your first monster in battle.", "combat", "task", "iron_sword", "dirt", "bronze", "stat", "mob_kills", 1, 100, 0), - ("ach_monster_hunter", "Monster Hunter", "Defeat 100 hostile mobs across servers.", "combat", "goal", "diamond_sword", "stone", "gold", "stat", "mob_kills", 100, 500, 0), - ("ach_master_slayer", "Slayer of the Night", "Vanquish 500 monsters threatening players.", "combat", "challenge", "netherite_sword", "obsidian", "purple", "stat", "mob_kills", 500, 1500, 0), - ("ach_duelist", "Arena Duelist", "Defeat another player in PvP combat.", "combat", "task", "bow", "stone", "silver", "stat", "player_kills", 1, 200, 0), - ("ach_gladiator", "SCOPENET Gladiator", "Defeat 25 players in competitive combat.", "combat", "challenge", "netherite_axe", "bedrock", "gold", "stat", "player_kills", 25, 1000, 0), - ("ach_architect", "Apprentice Architect", "Place your first 500 building blocks.", "building", "task", "bricks", "stone", "bronze", "stat", "blocks_placed", 500, 250, 0), - ("ach_master_builder", "Master Builder", "Construct structures using over 5,000 blocks.", "building", "goal", "stone_bricks", "oak_planks", "gold", "stat", "blocks_placed", 5000, 1000, 0), - ("ach_monument_maker", "Wonders of the World", "Place 20,000 blocks leaving your mark on the realm.", "building", "challenge", "beacon", "obsidian", "diamond", "stat", "blocks_placed", 20000, 2500, 0), - ("ach_playtime_10h", "Seasoned Traveler", "Accumulate 10 hours of playtime.", "progression", "task", "clock", "oak_planks", "silver", "stat", "playtime_secs", 36000, 500, 0), - ("ach_playtime_50h", "Hardened Veteran", "Reach 50 hours of active adventure.", "progression", "goal", "compass", "stone", "gold", "stat", "playtime_secs", 180000, 1200, 0), - ("ach_playtime_100h", "Centurion", "Dedicate 100 hours to the SCOPENET community.", "progression", "challenge", "totem_of_undying", "gilded_blackstone", "diamond", "stat", "playtime_secs", 360000, 2500, 0), - ("ach_level_10", "Rising Star", "Reach SCOPENET Global Level 10.", "progression", "task", "experience_bottle", "stone", "bronze", "level", "global_level", 10, 300, 0), - ("ach_level_25", "Champion", "Ascend to Global Level 25.", "progression", "goal", "enchanted_book", "stone", "gold", "level", "global_level", 25, 800, 0), - ("ach_level_50", "Grandmaster", "Attain Global Level 50 through valor and persistence.", "progression", "challenge", "nether_star", "obsidian", "purple", "level", "global_level", 50, 2000, 0), - ("ach_level_100", "Mythic Legend", "Surpass all expectations by reaching Global Level 100.", "progression", "challenge", "dragon_egg", "bedrock", "gold", "level", "global_level", 100, 5000, 0), - ("ach_chatty", "Social Butterfly", "Send 100 messages in server chat.", "social", "task", "book", "oak_planks", "bronze", "stat", "messages", 100, 200, 0), - ("ach_guild_initiate", "United We Stand", "Join or found a Guild.", "guilds", "task", "shield", "stone", "silver", "event", "join_guild", 1, 300, 0), - ("ach_land_claim", "Territorial Sovereign", "Claim your first chunk of guild territory.", "guilds", "goal", "grass_block", "dirt", "gold", "event", "claim_land", 1, 400, 0), - ("ach_nether_walker", "Into the Fire", "Set foot in the Nether dimension.", "exploration", "task", "flint_and_steel", "netherrack", "bronze", "event", "nether", 1, 200, 0), - ("ach_end_slayer", "Free the End", "Slay the Ender Dragon.", "exploration", "challenge", "dragon_head", "end_stone", "purple", "event", "ender_dragon", 1, 2000, 1), + ( + "ach_first_steps", + "First Steps", + "Join your first SCOPENET server and begin your journey.", + "progression", + "task", + "wooden_pickaxe", + "stone", + "bronze", + "stat", + "joins", + 1, + 100, + 0, + ), + ( + "ach_miner_49er", + "Strike It Rich!", + "Mine your first diamond ore underground.", + "mining", + "task", + "diamond", + "deepslate", + "gold", + "stat", + "blocks_broken", + 50, + 250, + 0, + ), + ( + "ach_first_blood", + "First Blood", + "Defeat your first monster in battle.", + "combat", + "task", + "iron_sword", + "dirt", + "bronze", + "stat", + "mob_kills", + 1, + 100, + 0, + ), + ( + "ach_monster_hunter", + "Monster Hunter", + "Defeat 100 hostile mobs across servers.", + "combat", + "goal", + "diamond_sword", + "stone", + "gold", + "stat", + "mob_kills", + 100, + 500, + 0, + ), + ( + "ach_master_slayer", + "Slayer of the Night", + "Vanquish 500 monsters threatening players.", + "combat", + "challenge", + "netherite_sword", + "obsidian", + "purple", + "stat", + "mob_kills", + 500, + 1500, + 0, + ), + ( + "ach_duelist", + "Arena Duelist", + "Defeat another player in PvP combat.", + "combat", + "task", + "bow", + "stone", + "silver", + "stat", + "player_kills", + 1, + 200, + 0, + ), + ( + "ach_gladiator", + "SCOPENET Gladiator", + "Defeat 25 players in competitive combat.", + "combat", + "challenge", + "netherite_axe", + "bedrock", + "gold", + "stat", + "player_kills", + 25, + 1000, + 0, + ), + ( + "ach_architect", + "Apprentice Architect", + "Place your first 500 building blocks.", + "building", + "task", + "bricks", + "stone", + "bronze", + "stat", + "blocks_placed", + 500, + 250, + 0, + ), + ( + "ach_master_builder", + "Master Builder", + "Construct structures using over 5,000 blocks.", + "building", + "goal", + "stone_bricks", + "oak_planks", + "gold", + "stat", + "blocks_placed", + 5000, + 1000, + 0, + ), + ( + "ach_monument_maker", + "Wonders of the World", + "Place 20,000 blocks leaving your mark on the realm.", + "building", + "challenge", + "beacon", + "obsidian", + "diamond", + "stat", + "blocks_placed", + 20000, + 2500, + 0, + ), + ( + "ach_playtime_10h", + "Seasoned Traveler", + "Accumulate 10 hours of playtime.", + "progression", + "task", + "clock", + "oak_planks", + "silver", + "stat", + "playtime_secs", + 36000, + 500, + 0, + ), + ( + "ach_playtime_50h", + "Hardened Veteran", + "Reach 50 hours of active adventure.", + "progression", + "goal", + "compass", + "stone", + "gold", + "stat", + "playtime_secs", + 180000, + 1200, + 0, + ), + ( + "ach_playtime_100h", + "Centurion", + "Dedicate 100 hours to the SCOPENET community.", + "progression", + "challenge", + "totem_of_undying", + "gilded_blackstone", + "diamond", + "stat", + "playtime_secs", + 360000, + 2500, + 0, + ), + ( + "ach_level_10", + "Rising Star", + "Reach SCOPENET Global Level 10.", + "progression", + "task", + "experience_bottle", + "stone", + "bronze", + "level", + "global_level", + 10, + 300, + 0, + ), + ( + "ach_level_25", + "Champion", + "Ascend to Global Level 25.", + "progression", + "goal", + "enchanted_book", + "stone", + "gold", + "level", + "global_level", + 25, + 800, + 0, + ), + ( + "ach_level_50", + "Grandmaster", + "Attain Global Level 50 through valor and persistence.", + "progression", + "challenge", + "nether_star", + "obsidian", + "purple", + "level", + "global_level", + 50, + 2000, + 0, + ), + ( + "ach_level_100", + "Mythic Legend", + "Surpass all expectations by reaching Global Level 100.", + "progression", + "challenge", + "dragon_egg", + "bedrock", + "gold", + "level", + "global_level", + 100, + 5000, + 0, + ), + ( + "ach_chatty", + "Social Butterfly", + "Send 100 messages in server chat.", + "social", + "task", + "book", + "oak_planks", + "bronze", + "stat", + "messages", + 100, + 200, + 0, + ), + ( + "ach_guild_initiate", + "United We Stand", + "Join or found a Guild.", + "guilds", + "task", + "shield", + "stone", + "silver", + "event", + "join_guild", + 1, + 300, + 0, + ), + ( + "ach_land_claim", + "Territorial Sovereign", + "Claim your first chunk of guild territory.", + "guilds", + "goal", + "grass_block", + "dirt", + "gold", + "event", + "claim_land", + 1, + 400, + 0, + ), + ( + "ach_nether_walker", + "Into the Fire", + "Set foot in the Nether dimension.", + "exploration", + "task", + "flint_and_steel", + "netherrack", + "bronze", + "event", + "nether", + 1, + 200, + 0, + ), + ( + "ach_end_slayer", + "Free the End", + "Slay the Ender Dragon.", + "exploration", + "challenge", + "dragon_head", + "end_stone", + "purple", + "event", + "ender_dragon", + 1, + 2000, + 1, + ), ]; for (id, title, desc, cat, frame, item, bg, border, req_type, req_key, req_val, xp, secret) in raw_achievements { @@ -324,7 +1176,7 @@ async fn seed_rewards(pool: &SqlitePool) -> Result<()> { for (lvl_type, srv_id, lvl_req, rew_type, rew_name, rew_data) in raw_rewards { sqlx::query( "INSERT INTO level_rewards (level_type, server_id, level_req, reward_type, reward_name, reward_data, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)" + VALUES (?, ?, ?, ?, ?, ?, ?)", ) .bind(lvl_type) .bind(srv_id) diff --git a/panel/server/src/worldmap.rs b/panel/server/src/worldmap.rs index 6477391..34f8357 100644 --- a/panel/server/src/worldmap.rs +++ b/panel/server/src/worldmap.rs @@ -67,7 +67,10 @@ pub fn dim_slug(id: &str) -> Option { "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 = rd.flatten().filter(|e| e.path().is_dir()).filter_map(|e| e.file_name().to_str().map(String::from)).collect(); + let mut names: Vec = + 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::(&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::(&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); diff --git a/panel/server/tests/claim_index.rs b/panel/server/tests/claim_index.rs index beec243..2eb0d1a 100644 --- a/panel/server/tests/claim_index.rs +++ b/panel/server/tests/claim_index.rs @@ -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); diff --git a/panel/server/tests/dev_api.rs b/panel/server/tests/dev_api.rs index 1c5f9a6..6351343 100644 --- a/panel/server/tests/dev_api.rs +++ b/panel/server/tests/dev_api.rs @@ -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); diff --git a/panel/server/tests/discord_studio.rs b/panel/server/tests/discord_studio.rs index 8c0937a..aef2125 100644 --- a/panel/server/tests/discord_studio.rs +++ b/panel/server/tests/discord_studio.rs @@ -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; diff --git a/panel/server/tests/emails.rs b/panel/server/tests/emails.rs index 622572c..cb98482 100644 --- a/panel/server/tests/emails.rs +++ b/panel/server/tests/emails.rs @@ -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("admin") && 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::() @@ -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"); diff --git a/panel/server/tests/features_v2.rs b/panel/server/tests/features_v2.rs index 22dccce..0e9d814 100644 --- a/panel/server/tests/features_v2.rs +++ b/panel/server/tests/features_v2.rs @@ -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); diff --git a/panel/server/tests/guild_bank.rs b/panel/server/tests/guild_bank.rs index 7a83005..2330523 100644 --- a/panel/server/tests/guild_bank.rs +++ b/panel/server/tests/guild_bank.rs @@ -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 = 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 = 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 = + 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 = 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); } diff --git a/panel/server/tests/guild_roles.rs b/panel/server/tests/guild_roles.rs index d891bb4..8974888 100644 --- a/panel/server/tests/guild_roles.rs +++ b/panel/server/tests/guild_roles.rs @@ -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(); diff --git a/panel/server/tests/integrations.rs b/panel/server/tests/integrations.rs index 0479d3c..f61bc37 100644 --- a/panel/server/tests/integrations.rs +++ b/panel/server/tests/integrations.rs @@ -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 = 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(); diff --git a/panel/server/tests/map.rs b/panel/server/tests/map.rs index d7c996b..fb692e3 100644 --- a/panel/server/tests/map.rs +++ b/panel/server/tests/map.rs @@ -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; diff --git a/panel/server/tests/network.rs b/panel/server/tests/network.rs index 612b8cb..09ddb78 100644 --- a/panel/server/tests/network.rs +++ b/panel/server/tests/network.rs @@ -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"}))).await; + let (s, _) = t + .call( + "PUT", + &format!("/api/admin/servers/{}", ids[2]), + Some(&admin), + Some(json!({"name": "Modded", "instance_id": "modded", "economy_group": "a"})), + ) + .await; assert_eq!(s, StatusCode::BAD_REQUEST); // Removing the server that holds the shared balances keeps them for the rest of the group. diff --git a/panel/server/tests/profile.rs b/panel/server/tests/profile.rs index 3c9eb13..6b42f39 100644 --- a/panel/server/tests/profile.rs +++ b/panel/server/tests/profile.rs @@ -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); } diff --git a/panel/server/tests/public_map.rs b/panel/server/tests/public_map.rs index 4fdd9f8..4d3e362 100644 --- a/panel/server/tests/public_map.rs +++ b/panel/server/tests/public_map.rs @@ -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); } diff --git a/panel/server/tests/rank_titles.rs b/panel/server/tests/rank_titles.rs index bf88552..cce6ceb 100644 --- a/panel/server/tests/rank_titles.rs +++ b/panel/server/tests/rank_titles.rs @@ -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(); diff --git a/panel/server/tests/rewards.rs b/panel/server/tests/rewards.rs index 2ad7109..4a776aa 100644 --- a/panel/server/tests/rewards.rs +++ b/panel/server/tests/rewards.rs @@ -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 = 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] diff --git a/panel/server/tests/social.rs b/panel/server/tests/social.rs index c957775..9273589 100644 --- a/panel/server/tests/social.rs +++ b/panel/server/tests/social.rs @@ -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 { - let tables: Vec = 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 = 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 = sqlx::query_scalar(&format!("SELECT name FROM pragma_table_info('{table}')")).fetch_all(&t.db).await.unwrap(); + let cols: Vec = + 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()); diff --git a/panel/server/tests/worldmap.rs b/panel/server/tests/worldmap.rs index e3fa456..fba5341 100644 --- a/panel/server/tests/worldmap.rs +++ b/panel/server/tests/worldmap.rs @@ -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; diff --git a/release-artifacts/0.5.0/SCOPENET Launcher_0.5.0_x64-setup.exe b/release-artifacts/0.5.0/SCOPENET Launcher_0.5.0_x64-setup.exe index 530fab3..42830d2 100644 Binary files a/release-artifacts/0.5.0/SCOPENET Launcher_0.5.0_x64-setup.exe and b/release-artifacts/0.5.0/SCOPENET Launcher_0.5.0_x64-setup.exe differ diff --git a/release-artifacts/0.5.0/SHA256SUMS b/release-artifacts/0.5.0/SHA256SUMS index 697b964..863b8be 100644 --- a/release-artifacts/0.5.0/SHA256SUMS +++ b/release-artifacts/0.5.0/SHA256SUMS @@ -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 diff --git a/release-artifacts/0.5.0/scopenet-client-fabric-1.20.1-0.5.0.jar b/release-artifacts/0.5.0/scopenet-client-fabric-1.20.1-0.5.0.jar index 990a3fd..71d4c78 100644 Binary files a/release-artifacts/0.5.0/scopenet-client-fabric-1.20.1-0.5.0.jar and b/release-artifacts/0.5.0/scopenet-client-fabric-1.20.1-0.5.0.jar differ diff --git a/release-artifacts/0.5.0/scopenet-fabric-1.20.1-0.5.0.jar b/release-artifacts/0.5.0/scopenet-fabric-1.20.1-0.5.0.jar index 0d04cee..28d34b3 100644 Binary files a/release-artifacts/0.5.0/scopenet-fabric-1.20.1-0.5.0.jar and b/release-artifacts/0.5.0/scopenet-fabric-1.20.1-0.5.0.jar differ diff --git a/release-artifacts/0.5.0/scopenet-fabric-1.21.1-0.5.0.jar b/release-artifacts/0.5.0/scopenet-fabric-1.21.1-0.5.0.jar index ffb095d..c7817ef 100644 Binary files a/release-artifacts/0.5.0/scopenet-fabric-1.21.1-0.5.0.jar and b/release-artifacts/0.5.0/scopenet-fabric-1.21.1-0.5.0.jar differ diff --git a/release-artifacts/0.5.0/scopenet-fabric-26.3-0.5.0.jar b/release-artifacts/0.5.0/scopenet-fabric-26.3-0.5.0.jar index 324e6c5..8b6b125 100644 Binary files a/release-artifacts/0.5.0/scopenet-fabric-26.3-0.5.0.jar and b/release-artifacts/0.5.0/scopenet-fabric-26.3-0.5.0.jar differ diff --git a/release-artifacts/0.5.0/scopenet-forge-1.20.1-0.5.0.jar b/release-artifacts/0.5.0/scopenet-forge-1.20.1-0.5.0.jar index ddf312e..7b2a8c3 100644 Binary files a/release-artifacts/0.5.0/scopenet-forge-1.20.1-0.5.0.jar and b/release-artifacts/0.5.0/scopenet-forge-1.20.1-0.5.0.jar differ diff --git a/release-artifacts/0.5.0/scopenet-forge-1.21.1-0.5.0.jar b/release-artifacts/0.5.0/scopenet-forge-1.21.1-0.5.0.jar index a58f608..b75e462 100644 Binary files a/release-artifacts/0.5.0/scopenet-forge-1.21.1-0.5.0.jar and b/release-artifacts/0.5.0/scopenet-forge-1.21.1-0.5.0.jar differ diff --git a/release-artifacts/0.5.0/scopenet-forge-26.3-0.5.0.jar b/release-artifacts/0.5.0/scopenet-forge-26.3-0.5.0.jar index 1dbc666..275f95c 100644 Binary files a/release-artifacts/0.5.0/scopenet-forge-26.3-0.5.0.jar and b/release-artifacts/0.5.0/scopenet-forge-26.3-0.5.0.jar differ diff --git a/release-artifacts/0.5.0/scopenet-paper-0.5.0.jar b/release-artifacts/0.5.0/scopenet-paper-0.5.0.jar index 45771d6..ec7f840 100644 Binary files a/release-artifacts/0.5.0/scopenet-paper-0.5.0.jar and b/release-artifacts/0.5.0/scopenet-paper-0.5.0.jar differ