//! Per-server economy system, marketplace, transactions, and baltop. use crate::auth::AuthUser; use crate::error::{AppError, AppResult}; use crate::routes::servers::GameServer; use crate::state::AppState; use axum::extract::{Path, State}; use axum::Json; use scopenet_shared::{BaltopEntry, EconomyTransaction, MarketListing, ServerEconomyBalance}; use serde::Deserialize; use serde_json::Value; 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(); 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?; Some(serde_json::from_str(&raw)?) } else { None }; Ok((tx, previous)) } 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?; tx.commit().await?; Ok(Json(response)) } async fn ensure_balance(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, uuid: &str, name: &str) -> AppResult<()> { sqlx::query("INSERT INTO server_economy(server_id, uuid, username, balance, updated_at) VALUES (?, ?, ?, 1000, ?) ON CONFLICT(server_id, uuid) DO NOTHING") .bind(server_id).bind(uuid).bind(name).bind(chrono::Utc::now().to_rfc3339()).execute(&mut **tx).await?; Ok(()) } #[derive(Deserialize)] pub struct AdjustBalancePayload { pub uuid: String, pub username: String, pub delta: f64, pub operation_id: String, 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")); } 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?; 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?; 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 } pub async fn server_market_read(GameServer(server): GameServer, State(state): State) -> AppResult>> { let rows: Vec<(i64, String, String, String, i32, f64, Option)> = sqlx::query_as("SELECT id, seller_name, item_id, item_name, amount, price, item_data FROM server_market WHERE server_id = ? ORDER BY 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)| serde_json::json!({"id":id,"seller_name":seller,"item_id":item_id,"item_name":item_name,"amount":amount,"price":price,"item_data":data})).collect())) } // --------------------------------------------------------------------------- // Launcher & Public Economy API // --------------------------------------------------------------------------- /// Get current user's economy balances across all connected game servers. pub async fn get_my_balances( auth: AuthUser, State(state): State, ) -> AppResult>> { let rows: Vec<(i64, String, f64)> = sqlx::query_as( "SELECT se.server_id, gs.name, se.balance FROM server_economy se JOIN game_servers gs ON gs.id = se.server_id WHERE se.uuid = ? ORDER BY se.balance DESC", ) .bind(&auth.uuid) .fetch_all(&state.db) .await?; let list = rows .into_iter() .map(|(sid, sname, bal)| ServerEconomyBalance { server_id: sid, server_name: sname, balance: bal, currency_symbol: "$".into(), }) .collect(); Ok(Json(list)) } /// Get current user's recent transactions across all servers. pub async fn get_my_transactions( auth: AuthUser, State(state): State, ) -> 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 JOIN game_servers gs ON gs.id = et.server_id WHERE et.from_uuid = ? OR et.to_uuid = ? ORDER BY et.id DESC LIMIT 50", ) .bind(&auth.uuid) .bind(&auth.uuid) .fetch_all(&state.db) .await?; let list = rows .into_iter() .map(|(id, sid, sname, fuuid, fname, tuuid, tname, amount, desc, created)| EconomyTransaction { id, server_id: sid, server_name: Some(sname), from_uuid: fuuid, from_name: fname, to_uuid: tuuid, to_name: tname, amount, description: desc, created_at: created, }) .collect(); Ok(Json(list)) } /// Get top richest players for a specific server. pub async fn get_server_baltop( Path(server_id): Path, State(state): State, ) -> AppResult>> { let rows: Vec<(String, String, f64)> = sqlx::query_as( "SELECT uuid, username, balance FROM server_economy WHERE server_id = ? ORDER BY balance DESC LIMIT 25", ) .bind(server_id) .fetch_all(&state.db) .await?; let list = rows .into_iter() .enumerate() .map(|(i, (uuid, username, balance))| BaltopEntry { rank: i + 1, uuid, username, balance, }) .collect(); Ok(Json(list)) } /// Get active marketplace listings for a server. pub async fn get_server_market( Path(server_id): Path, State(state): State, ) -> AppResult>> { let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String)> = sqlx::query_as( "SELECT id, server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at FROM server_market WHERE server_id = ? ORDER BY id DESC LIMIT 100", ) .bind(server_id) .fetch_all(&state.db) .await?; let list = rows .into_iter() .map(|(id, sid, suuid, sname, iid, iname, amt, price, created)| MarketListing { id, server_id: sid, seller_uuid: suuid, seller_name: sname, item_id: iid, item_name: iname, amount: amt, price, created_at: created, }) .collect(); Ok(Json(list)) } // --------------------------------------------------------------------------- // Server Integration Relay Endpoints // --------------------------------------------------------------------------- #[derive(Deserialize)] pub struct ServerBalanceQuery { pub uuid: String, pub username: Option, } pub async fn server_get_balance( GameServer(server): GameServer, State(state): State, Json(payload): Json, ) -> AppResult> { 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.id) .bind(&payload.uuid) .fetch_optional(&state.db) .await?; let balance = match bal_opt { Some(b) => b, None => { let starting_balance = 1000.0; sqlx::query( "INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(server_id, uuid) DO UPDATE SET username = excluded.username", ) .bind(server.id) .bind(&payload.uuid) .bind(&username) .bind(starting_balance) .bind(&now) .execute(&state.db) .await?; starting_balance } }; Ok(Json(serde_json::json!({ "server_id": server.id, "uuid": payload.uuid, "balance": balance, "currency_symbol": "$" }))) } #[derive(Deserialize)] pub struct ServerTransferPayload { pub from_uuid: String, pub from_name: String, pub to_uuid: String, pub to_name: String, pub amount: f64, pub description: Option, } pub async fn server_transfer( GameServer(server): GameServer, State(state): State, Json(payload): Json, ) -> AppResult> { if !payload.amount.is_finite() || payload.amount <= 0.0 { return Err(AppError::bad_request("Transfer amount must be positive")); } let mut tx = state.db.begin().await?; let now = chrono::Utc::now().to_rfc3339(); // Check sender balance let from_bal: Option = sqlx::query_scalar( "SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?", ) .bind(server.id) .bind(&payload.from_uuid) .fetch_optional(&mut *tx) .await?; let current_from_bal = from_bal.unwrap_or(0.0); if current_from_bal < payload.amount { return Err(AppError::bad_request("Insufficient funds")); } let new_from_bal = current_from_bal - payload.amount; sqlx::query("UPDATE server_economy SET balance = ?, updated_at = ? WHERE server_id = ? AND uuid = ?") .bind(new_from_bal) .bind(&now) .bind(server.id) .bind(&payload.from_uuid) .execute(&mut *tx) .await?; // Update receiver sqlx::query( "INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (server_id, uuid) DO UPDATE SET balance = balance + excluded.balance, username = excluded.username, updated_at = excluded.updated_at", ) .bind(server.id) .bind(&payload.to_uuid) .bind(&payload.to_name) .bind(payload.amount) .bind(&now) .execute(&mut *tx) .await?; let desc = payload.description.unwrap_or_else(|| format!("Payment to {}", payload.to_name)); sqlx::query( "INSERT INTO economy_transactions (server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(server.id) .bind(&payload.from_uuid) .bind(&payload.from_name) .bind(&payload.to_uuid) .bind(&payload.to_name) .bind(payload.amount) .bind(&desc) .bind(&now) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Json(serde_json::json!({ "ok": true, "from_balance": new_from_bal }))) } #[derive(Deserialize)] pub struct ServerSyncBalancePayload { pub uuid: String, pub username: String, pub new_balance: f64, } pub async fn server_sync_balance( GameServer(server): GameServer, State(state): State, Json(payload): Json, ) -> AppResult> { let now = chrono::Utc::now().to_rfc3339(); sqlx::query( "INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(server_id, uuid) DO UPDATE SET balance = excluded.balance, username = excluded.username, updated_at = excluded.updated_at", ) .bind(server.id) .bind(&payload.uuid) .bind(&payload.username) .bind(payload.new_balance) .bind(&now) .execute(&state.db) .await?; Ok(Json(serde_json::json!({ "ok": true }))) } pub async fn server_baltop( GameServer(server): GameServer, State(state): State, ) -> AppResult>> { let rows: Vec<(String, String, f64)> = sqlx::query_as( "SELECT uuid, username, balance FROM server_economy WHERE server_id = ? ORDER BY balance DESC LIMIT 20", ) .bind(server.id) .fetch_all(&state.db) .await?; let list = rows .into_iter() .enumerate() .map(|(i, (uuid, username, balance))| BaltopEntry { rank: i + 1, uuid, username, balance, }) .collect(); Ok(Json(list)) } #[derive(Deserialize)] pub struct MarketListPayload { pub operation_id: String, pub item_data: Option, pub seller_uuid: String, pub seller_name: String, pub item_id: String, pub item_name: String, pub amount: i32, pub price: f64, } pub async fn server_market_list( GameServer(server): GameServer, State(state): State, 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")); } let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?; if let Some(previous) = previous { return Ok(Json(previous)); } let now = chrono::Utc::now().to_rfc3339(); let id: i64 = sqlx::query_scalar( "INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at, item_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", ) .bind(server.id) .bind(payload.seller_uuid) .bind(payload.seller_name) .bind(payload.item_id) .bind(payload.item_name) .bind(payload.amount) .bind(payload.price) .bind(&now) .bind(&payload.item_data) .fetch_one(&mut *tx) .await?; finish_operation(tx, server.id, &payload.operation_id, serde_json::json!({ "id": id, "ok": true })).await } #[derive(Deserialize)] pub struct MarketBuyPayload { pub operation_id: String, pub listing_id: i64, pub buyer_uuid: String, pub buyer_name: String, } pub async fn server_market_buy( GameServer(server): GameServer, State(state): State, 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)); } let listing: Option<(String, String, String, String, i32, f64, Option)> = sqlx::query_as( "DELETE FROM server_market WHERE id = ? AND server_id = ? RETURNING seller_uuid, seller_name, item_id, item_name, amount, price, item_data ", ) .bind(payload.listing_id) .bind(server.id) .fetch_optional(&mut *tx) .await?; let Some((seller_uuid, seller_name, item_id, item_name, amount, price, item_data)) = listing else { return Err(AppError::not_found("Listing not found")); }; ensure_balance(&mut tx, server.id, &payload.buyer_uuid, &payload.buyer_name).await?; let now = chrono::Utc::now().to_rfc3339(); // Deduct buyer let buyer_bal: f64 = sqlx::query_scalar( "SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?", ) .bind(server.id) .bind(&payload.buyer_uuid) .fetch_optional(&mut *tx) .await? .unwrap_or(0.0); if buyer_bal < price { return Err(AppError::bad_request("Insufficient funds")); } sqlx::query("UPDATE server_economy SET balance = balance - ?, updated_at = ? WHERE server_id = ? AND uuid = ?") .bind(price) .bind(&now) .bind(server.id) .bind(&payload.buyer_uuid) .execute(&mut *tx) .await?; // Pay seller sqlx::query( "INSERT INTO server_economy (server_id, uuid, username, balance, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (server_id, uuid) DO UPDATE SET balance = balance + excluded.balance, username = excluded.username, updated_at = excluded.updated_at", ) .bind(server.id) .bind(&seller_uuid) .bind(&seller_name) .bind(price) .bind(&now) .execute(&mut *tx) .await?; // Delete listing sqlx::query("DELETE FROM server_market WHERE id = ?") .bind(payload.listing_id) .execute(&mut *tx) .await?; // Log transaction let desc = format!("Market purchase: {}x {} from {}", amount, item_name, seller_name); sqlx::query( "INSERT INTO economy_transactions (server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(server.id) .bind(&payload.buyer_uuid) .bind(&payload.buyer_name) .bind(&seller_uuid) .bind(&seller_name) .bind(price) .bind(&desc) .bind(&now) .execute(&mut *tx) .await?; 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 })).await }