diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index 016f13d..368f2d6 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -672,6 +672,10 @@ const MIGRATIONS: &[&str] = &[ r#" ALTER TABLE groups ADD COLUMN discord_role TEXT NOT NULL DEFAULT ''; "#, + // Servers can share one economy (balances and guild banks) by sharing a group name. + r#" + ALTER TABLE game_servers ADD COLUMN economy_group TEXT NOT NULL DEFAULT ''; + "#, ]; pub async fn connect(data_dir: &Path) -> Result { diff --git a/panel/server/src/routes/economy.rs b/panel/server/src/routes/economy.rs index 90eccc0..df92183 100644 --- a/panel/server/src/routes/economy.rs +++ b/panel/server/src/routes/economy.rs @@ -50,9 +50,9 @@ pub async fn server_adjust_balance(GameServer(server): GameServer, State(state): 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)); } - ensure_balance(&mut tx, server.id, &p.uuid, &p.username).await?; + 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.id).bind(&p.uuid).bind(p.delta).fetch_optional(&mut *tx).await?; + .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") }; sqlx::query("INSERT INTO economy_transactions(server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") @@ -229,7 +229,7 @@ pub async fn server_get_balance( let bal_opt: Option = sqlx::query_scalar( "SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?", ) - .bind(server.id) + .bind(server.economy_id) .bind(&payload.uuid) .fetch_optional(&state.db) .await?; @@ -243,7 +243,7 @@ pub async fn server_get_balance( VALUES (?, ?, ?, ?, ?) ON CONFLICT(server_id, uuid) DO UPDATE SET username = excluded.username", ) - .bind(server.id) + .bind(server.economy_id) .bind(&payload.uuid) .bind(&username) .bind(starting_balance) @@ -288,7 +288,7 @@ pub async fn server_transfer( let from_bal: Option = sqlx::query_scalar( "SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?", ) - .bind(server.id) + .bind(server.economy_id) .bind(&payload.from_uuid) .fetch_optional(&mut *tx) .await?; @@ -302,7 +302,7 @@ pub async fn server_transfer( sqlx::query("UPDATE server_economy SET balance = ?, updated_at = ? WHERE server_id = ? AND uuid = ?") .bind(new_from_bal) .bind(&now) - .bind(server.id) + .bind(server.economy_id) .bind(&payload.from_uuid) .execute(&mut *tx) .await?; @@ -316,7 +316,7 @@ pub async fn server_transfer( username = excluded.username, updated_at = excluded.updated_at", ) - .bind(server.id) + .bind(server.economy_id) .bind(&payload.to_uuid) .bind(&payload.to_name) .bind(payload.amount) @@ -369,7 +369,7 @@ pub async fn server_sync_balance( username = excluded.username, updated_at = excluded.updated_at", ) - .bind(server.id) + .bind(server.economy_id) .bind(&payload.uuid) .bind(&payload.username) .bind(payload.new_balance) @@ -391,7 +391,7 @@ pub async fn server_baltop( ORDER BY balance DESC LIMIT 20", ) - .bind(server.id) + .bind(server.economy_id) .fetch_all(&state.db) .await?; @@ -502,35 +502,35 @@ pub async fn server_market_buy( Some(m) } else { None }; - ensure_balance(&mut tx, server.id, &payload.buyer_uuid, &payload.buyer_name).await?; + ensure_balance(&mut tx, server.economy_id, &payload.buyer_uuid, &payload.buyer_name).await?; let now = chrono::Utc::now().to_rfc3339(); // 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.id, &m.guild_id, -price).await?; - crate::routes::guild_bank::log(&mut tx, server.id, &m.guild_id, &payload.buyer_uuid, "purchase", price, &format!("Market: {amount}x {item_name}")).await?; + 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?; left } else { let buyer_bal: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?") - .bind(server.id).bind(&payload.buyer_uuid).fetch_optional(&mut *tx).await?.unwrap_or(0.0); + .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.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.id, guild_id, price).await?; - crate::routes::guild_bank::log(&mut tx, server.id, guild_id, &seller_uuid, "sale", price, &format!("Market: {amount}x {item_name}")).await?; + 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?; (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.id, &seller_uuid, &seller_name).await?; + 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.id).bind(&seller_uuid) + .bind(price).bind(&seller_name).bind(&now).bind(server.economy_id).bind(&seller_uuid) .execute(&mut *tx).await?; (seller_uuid.clone(), seller_name.clone()) }; diff --git a/panel/server/src/routes/guild_bank.rs b/panel/server/src/routes/guild_bank.rs index 225c7cf..956397f 100644 --- a/panel/server/src/routes/guild_bank.rs +++ b/panel/server/src/routes/guild_bank.rs @@ -101,7 +101,7 @@ pub async fn server_bank_info(GameServer(server): GameServer, State(state): Stat return Ok(Json(json!({ "guild": null }))); }; let balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE server_id = ? AND guild_id = ?") - .bind(server.id) + .bind(server.economy_id) .bind(&m.guild_id) .fetch_optional(&mut *conn) .await? @@ -110,7 +110,7 @@ pub async fn server_bank_info(GameServer(server): GameServer, State(state): Stat "SELECT t.kind, COALESCE((SELECT gm.name FROM guild_members gm WHERE gm.guild_id = t.guild_id AND gm.uuid = t.actor_uuid), 'Former member'), t.amount, t.note, t.created_at FROM guild_wallet_transactions t WHERE t.server_id = ? AND t.guild_id = ? ORDER BY t.id DESC LIMIT 8", ) - .bind(server.id) + .bind(server.economy_id) .bind(&m.guild_id) .fetch_all(&mut *conn) .await?; @@ -119,7 +119,7 @@ pub async fn server_bank_info(GameServer(server): GameServer, State(state): Stat "role": m.role, "can_spend": m.can_spend(), "balance": balance, - "my_balance": player_balance(&mut conn, server.id, &p.uuid).await?, + "my_balance": player_balance(&mut conn, server.economy_id, &p.uuid).await?, "recent": recent.into_iter().map(|(kind, who, amount, note, at)| json!({ "kind": kind, "who": who, "amount": amount, "note": note, "at": at })).collect::>(), }))) } @@ -149,21 +149,21 @@ pub async fn server_bank_transfer(GameServer(server): GameServer, State(state): if withdraw && !m.can_spend() { return Err(AppError::forbidden("Only guild leaders and officers can withdraw")); } - ensure_balance(&mut tx, server.id, &p.uuid, &p.username).await?; + ensure_balance(&mut tx, server.economy_id, &p.uuid, &p.username).await?; let (guild_delta, player_delta) = if withdraw { (-p.amount, p.amount) } else { (p.amount, -p.amount) }; let moved: Option = sqlx::query_scalar( "UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ? AND balance + ? >= 0 RETURNING balance", ) .bind(player_delta) .bind(chrono::Utc::now().to_rfc3339()) - .bind(server.id) + .bind(server.economy_id) .bind(&p.uuid) .bind(player_delta) .fetch_optional(&mut *tx) .await?; let my_balance = moved.ok_or_else(|| AppError::bad_request("Insufficient funds"))?; - let balance = adjust_wallet(&mut tx, server.id, &m.guild_id, guild_delta).await?; - log(&mut tx, server.id, &m.guild_id, &p.uuid, &p.action, p.amount, "").await?; + 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 } @@ -186,9 +186,9 @@ pub async fn server_bank_credit(GameServer(server): GameServer, State(state): St return Ok(Json(previous)); } let m = membership(&mut tx, &server, &p.uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?; - let balance = adjust_wallet(&mut tx, server.id, &m.guild_id, p.amount).await?; + let balance = adjust_wallet(&mut tx, server.economy_id, &m.guild_id, p.amount).await?; let note = if p.description.is_empty() { "Items sold to the server shop" } else { &p.description }; - log(&mut tx, server.id, &m.guild_id, &p.uuid, "sale", p.amount, note).await?; + log(&mut tx, server.economy_id, &m.guild_id, &p.uuid, "sale", p.amount, note).await?; let message = format!("Sold for ${:.2}, paid into the [{}] bank.", p.amount, m.tag); finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "balance": balance, "message": message })).await } @@ -223,11 +223,11 @@ pub async fn server_bank_pay(GameServer(server): GameServer, State(state): State .fetch_optional(&mut *tx) .await?; let (target_id, target_name, target_tag) = target.ok_or_else(|| AppError::not_found("No other guild with that tag"))?; - let balance = adjust_wallet(&mut tx, server.id, &m.guild_id, -p.amount).await?; - adjust_wallet(&mut tx, server.id, &target_id, p.amount).await?; + 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.id, &m.guild_id, &p.uuid, "transfer_out", p.amount, &format!("to [{target_tag}] {target_name} {note}")).await?; - log(&mut tx, server.id, &target_id, &p.uuid, "transfer_in", p.amount, &format!("from [{}] {} {note}", m.tag, m.name)).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 136a942..6930a90 100644 --- a/panel/server/src/routes/guilds.rs +++ b/panel/server/src/routes/guilds.rs @@ -148,15 +148,16 @@ pub async fn guild_wallet( 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")); } + 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(query.server_id).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(query.server_id).fetch_all(&state.db).await?; + .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?; // 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(query.server_id).bind(&auth.uuid).fetch_optional(&state.db).await?.unwrap_or(1000.0); + .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::>()}))) } @@ -176,6 +177,9 @@ async fn wallet_transfer(state: &AppState, auth: &AuthUser, guild_id: &str, p: G .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")); } + // 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. diff --git a/panel/server/src/routes/servers.rs b/panel/server/src/routes/servers.rs index 71aed53..e94dc07 100644 --- a/panel/server/src/routes/servers.rs +++ b/panel/server/src/routes/servers.rs @@ -63,6 +63,14 @@ pub struct ServerRow { pub instance_id: String, pub map_url: String, pub live_map_enabled: bool, + /// Servers with the same non-empty name share player balances and guild banks. + #[sqlx(default)] + pub economy_group: String, + /// The server whose balances this one uses: itself, or the oldest in its economy group. + /// Only filled in for token-authenticated game server requests. + #[sqlx(default)] + #[serde(skip)] + pub economy_id: i64, pub id: i64, pub name: String, #[serde(skip)] @@ -122,10 +130,24 @@ impl FromRequestParts for GameServer { .bind(hash_token(token.trim())) .fetch_optional(&state.db) .await?; - row.map(GameServer).ok_or_else(|| AppError::unauthorized("unknown server token — create one under Servers in the panel")) + let mut server = row.ok_or_else(|| AppError::unauthorized("unknown server token — create one under Servers in the panel"))?; + server.economy_id = economy_scope(&state.db, server.id).await?; + Ok(GameServer(server)) } } +/// Which server's balances `server_id` uses (see [`ServerRow::economy_group`]). +pub async fn economy_scope(db: &sqlx::SqlitePool, server_id: i64) -> AppResult { + Ok(sqlx::query_scalar( + "SELECT COALESCE((SELECT MIN(b.id) FROM game_servers b WHERE b.economy_group <> '' AND b.economy_group = a.economy_group COLLATE NOCASE), a.id) + FROM game_servers a WHERE a.id = ?", + ) + .bind(server_id) + .fetch_optional(db) + .await? + .unwrap_or(server_id)) +} + #[derive(Deserialize, Default)] #[serde(default)] pub struct Hello { @@ -845,6 +867,8 @@ pub struct ServerInput { map_url: String, #[serde(default)] live_map_enabled: bool, + #[serde(default)] + economy_group: String, } fn default_access() -> String { @@ -862,6 +886,10 @@ impl ServerInput { if self.access == "groups" && self.allowed_groups.is_empty() { return Err(AppError::bad_request("pick at least one group")); } + let group = self.economy_group.trim(); + if group.chars().count() > 32 || !group.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_')) { + return Err(AppError::bad_request("economy groups are up to 32 letters, digits, spaces, - or _")); + } if !self.map_url.trim().is_empty() { let url = reqwest::Url::parse(self.map_url.trim()).map_err(|_| AppError::bad_request("map URL must be an absolute URL"))?; if url.scheme() != "https" && !(url.scheme() == "http" && matches!(url.host_str(), Some("localhost" | "127.0.0.1"))) { @@ -890,7 +918,7 @@ pub async fn create(_: AdminUser, State(state): State, Json(input): Js input.validate()?; let token = new_token(); let id: i64 = sqlx::query_scalar( - "INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id, map_url, live_map_enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + "INSERT INTO game_servers (name, token_hash, token_hint, access, allowed_groups, require_launcher, created_at, instance_id, map_url, live_map_enabled, economy_group) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", ) .bind(input.name.trim()) .bind(hash_token(&token)) @@ -902,6 +930,7 @@ pub async fn create(_: AdminUser, State(state): State, Json(input): Js .bind(&input.instance_id) .bind(input.map_url.trim()) .bind(input.live_map_enabled) + .bind(input.economy_group.trim()) .fetch_one(&state.db) .await?; let server = get_server(&state, id).await?; @@ -916,7 +945,7 @@ pub async fn update( ) -> AppResult> { input.validate()?; get_server(&state, id).await?; - sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ?, map_url = ?, live_map_enabled = ? WHERE id = ?") + sqlx::query("UPDATE game_servers SET name = ?, access = ?, allowed_groups = ?, require_launcher = ?, instance_id = ?, map_url = ?, live_map_enabled = ?, economy_group = ? WHERE id = ?") .bind(input.name.trim()) .bind(&input.access) .bind(serde_json::to_string(&input.allowed_groups).unwrap_or_else(|_| "[]".into())) @@ -924,6 +953,7 @@ pub async fn update( .bind(&input.instance_id) .bind(input.map_url.trim()) .bind(input.live_map_enabled) + .bind(input.economy_group.trim()) .bind(id) .execute(&state.db) .await?; @@ -943,6 +973,17 @@ 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(); + 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?; + 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 = ?") }; + sqlx::query(&sql).bind(next).bind(id).execute(&state.db).await?; + } + } + } let done = sqlx::query("DELETE FROM game_servers WHERE id = ?").bind(id).execute(&state.db).await?; if done.rows_affected() == 0 { return Err(AppError::not_found("server not found")); diff --git a/panel/server/tests/network.rs b/panel/server/tests/network.rs new file mode 100644 index 0000000..612b8cb --- /dev/null +++ b/panel/server/tests/network.rs @@ -0,0 +1,47 @@ +//! Cross-server network: servers in one economy group share balances and guild banks. + +mod common; +use common::*; + +#[tokio::test] +async fn servers_in_an_economy_group_share_balances() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Alex", "password": "password123"}))).await; + let uuid = t.uuid("Alex").await; + + 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; + 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 } + }; + let balance = |token: String| { + let (t, uuid) = (&t, uuid.clone()); + async move { t.call("POST", "/api/server/v1/economy/balance", Some(&token), Some(json!({"uuid": uuid, "username": "Alex"}))).await } + }; + + // Earning on one server shows up on the other in the group (new accounts start at 1000) … + let (s, r) = adjust(tokens[0].clone(), "a", 250.0).await; + assert_eq!(s, StatusCode::OK, "{r}"); + assert_eq!(balance(tokens[1].clone()).await.1["balance"], 1250.0); + // … spending on the second is visible on the first … + assert_eq!(adjust(tokens[1].clone(), "b", -50.0).await.0, StatusCode::OK); + assert_eq!(balance(tokens[0].clone()).await.1["balance"], 1200.0); + // … and a server outside the group keeps its own economy. + 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; + assert_eq!(s, StatusCode::BAD_REQUEST); + + // Removing the server that holds the shared balances keeps them for the rest of the group. + assert_eq!(t.call("DELETE", &format!("/api/admin/servers/{}", ids[0]), Some(&admin), None).await.0, StatusCode::OK); + assert_eq!(balance(tokens[1].clone()).await.1["balance"], 1200.0); +} diff --git a/panel/web/src/components/ServerForm.svelte b/panel/web/src/components/ServerForm.svelte index 29c8876..5fa24da 100644 --- a/panel/web/src/components/ServerForm.svelte +++ b/panel/web/src/components/ServerForm.svelte @@ -23,6 +23,7 @@ label="Live Map" help="The SCOPENET plugin/mod sends this server's chunks and player positions to the panel, which hosts a Vantage 3D map. Launchers show a Live Map button — nothing to run next to the server." /> +
Use an external Vantage map instead diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts index 888315f..a3547ad 100644 --- a/panel/web/src/lib/types.ts +++ b/panel/web/src/lib/types.ts @@ -95,6 +95,7 @@ export interface GameServer { instance_id: string; map_url: string; live_map_enabled: boolean; + economy_group: string; id: number; name: string; token_hint: string; @@ -159,7 +160,7 @@ export interface LiveMapStatus { roster: { uuid: string; name: string; dimension: string; x: number; y: number; z: number }[]; } -export type ServerDraft = { instance_id?: string; name: string; map_url?: string; live_map_enabled: boolean; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; +export type ServerDraft = { instance_id?: string; name: string; map_url?: string; live_map_enabled: boolean; economy_group?: string; access: 'all' | 'members' | 'groups'; allowed_groups: string[]; require_launcher: boolean }; export interface HostedDownload { platform: 'windows' | 'mac' | 'linux'; diff --git a/panel/web/src/pages/ServerDetail.svelte b/panel/web/src/pages/ServerDetail.svelte index 8ba439e..ebb8b11 100644 --- a/panel/web/src/pages/ServerDetail.svelte +++ b/panel/web/src/pages/ServerDetail.svelte @@ -80,7 +80,7 @@ function openEdit() { if (!s) return; - draft = { instance_id: s.instance_id, name: s.name, map_url: s.map_url, live_map_enabled: s.live_map_enabled, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher }; + draft = { instance_id: s.instance_id, name: s.name, map_url: s.map_url, live_map_enabled: s.live_map_enabled, economy_group: s.economy_group, access: s.access, allowed_groups: [...s.allowed_groups], require_launcher: s.require_launcher }; editOpen = true; } async function saveEdit() {