549 lines
19 KiB
Rust
549 lines
19 KiB
Rust
//! 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<Value>)> {
|
|
if operation_id.is_empty() || operation_id.len() > 100 { return Err(AppError::bad_request("Invalid operation ID")); }
|
|
let mut tx = state.db.begin().await?;
|
|
// The first statement obtains the write lock before reading any balances.
|
|
let inserted = sqlx::query("INSERT OR IGNORE INTO economy_operations(server_id, operation_id, response) VALUES (?, ?, '')")
|
|
.bind(server_id).bind(operation_id).execute(&mut *tx).await?.rows_affected();
|
|
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<Json<Value>> {
|
|
sqlx::query("UPDATE economy_operations SET response = ? WHERE server_id = ? AND operation_id = ?")
|
|
.bind(response.to_string()).bind(server_id).bind(operation_id).execute(&mut *tx).await?;
|
|
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<AppState>, Json(p): Json<AdjustBalancePayload>) -> AppResult<Json<Value>> {
|
|
if !p.delta.is_finite() || p.delta == 0.0 || p.delta.abs() > 1e12 { return Err(AppError::bad_request("Invalid amount")); }
|
|
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
|
|
if let Some(previous) = previous { return Ok(Json(previous)); }
|
|
ensure_balance(&mut tx, server.id, &p.uuid, &p.username).await?;
|
|
let balance: Option<f64> = sqlx::query_scalar("UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ? AND balance + ? >= 0 RETURNING balance")
|
|
.bind(p.delta).bind(chrono::Utc::now().to_rfc3339()).bind(server.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<AppState>) -> AppResult<Json<Vec<Value>>> {
|
|
let rows: Vec<(i64, String, String, String, i32, f64, Option<String>)> = 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<AppState>,
|
|
) -> AppResult<Json<Vec<ServerEconomyBalance>>> {
|
|
let rows: Vec<(i64, String, f64)> = sqlx::query_as(
|
|
"SELECT se.server_id, gs.name, se.balance
|
|
FROM server_economy se
|
|
JOIN game_servers gs ON gs.id = se.server_id
|
|
WHERE se.uuid = ?
|
|
ORDER BY se.balance DESC",
|
|
)
|
|
.bind(&auth.uuid)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
|
|
let list = rows
|
|
.into_iter()
|
|
.map(|(sid, sname, bal)| ServerEconomyBalance {
|
|
server_id: sid,
|
|
server_name: sname,
|
|
balance: bal,
|
|
currency_symbol: "$".into(),
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(list))
|
|
}
|
|
|
|
/// Get current user's recent transactions across all servers.
|
|
pub async fn get_my_transactions(
|
|
auth: AuthUser,
|
|
State(state): State<AppState>,
|
|
) -> AppResult<Json<Vec<EconomyTransaction>>> {
|
|
let rows: Vec<(i64, i64, String, String, String, String, String, f64, String, String)> = sqlx::query_as(
|
|
"SELECT et.id, et.server_id, gs.name, et.from_uuid, et.from_name, et.to_uuid, et.to_name, et.amount, et.description, et.created_at
|
|
FROM economy_transactions et
|
|
JOIN game_servers gs ON gs.id = et.server_id
|
|
WHERE et.from_uuid = ? OR et.to_uuid = ?
|
|
ORDER BY et.id DESC
|
|
LIMIT 50",
|
|
)
|
|
.bind(&auth.uuid)
|
|
.bind(&auth.uuid)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
|
|
let list = rows
|
|
.into_iter()
|
|
.map(|(id, sid, sname, fuuid, fname, tuuid, tname, amount, desc, created)| EconomyTransaction {
|
|
id,
|
|
server_id: sid,
|
|
server_name: Some(sname),
|
|
from_uuid: fuuid,
|
|
from_name: fname,
|
|
to_uuid: tuuid,
|
|
to_name: tname,
|
|
amount,
|
|
description: desc,
|
|
created_at: created,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(list))
|
|
}
|
|
|
|
/// Get top richest players for a specific server.
|
|
pub async fn get_server_baltop(
|
|
Path(server_id): Path<i64>,
|
|
State(state): State<AppState>,
|
|
) -> AppResult<Json<Vec<BaltopEntry>>> {
|
|
let rows: Vec<(String, String, f64)> = sqlx::query_as(
|
|
"SELECT uuid, username, balance
|
|
FROM server_economy
|
|
WHERE server_id = ?
|
|
ORDER BY balance DESC
|
|
LIMIT 25",
|
|
)
|
|
.bind(server_id)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
|
|
let list = rows
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(i, (uuid, username, balance))| BaltopEntry {
|
|
rank: i + 1,
|
|
uuid,
|
|
username,
|
|
balance,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(list))
|
|
}
|
|
|
|
/// Get active marketplace listings for a server.
|
|
pub async fn get_server_market(
|
|
Path(server_id): Path<i64>,
|
|
State(state): State<AppState>,
|
|
) -> AppResult<Json<Vec<MarketListing>>> {
|
|
let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String)> = sqlx::query_as(
|
|
"SELECT id, server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at
|
|
FROM server_market
|
|
WHERE server_id = ?
|
|
ORDER BY id DESC
|
|
LIMIT 100",
|
|
)
|
|
.bind(server_id)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
|
|
let list = rows
|
|
.into_iter()
|
|
.map(|(id, sid, suuid, sname, iid, iname, amt, price, created)| MarketListing {
|
|
id,
|
|
server_id: sid,
|
|
seller_uuid: suuid,
|
|
seller_name: sname,
|
|
item_id: iid,
|
|
item_name: iname,
|
|
amount: amt,
|
|
price,
|
|
created_at: created,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(list))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server Integration Relay Endpoints
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ServerBalanceQuery {
|
|
pub uuid: String,
|
|
pub username: Option<String>,
|
|
}
|
|
|
|
pub async fn server_get_balance(
|
|
GameServer(server): GameServer,
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<ServerBalanceQuery>,
|
|
) -> AppResult<Json<Value>> {
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
let username = payload.username.unwrap_or_else(|| "Player".into());
|
|
|
|
let bal_opt: Option<f64> = sqlx::query_scalar(
|
|
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
|
|
)
|
|
.bind(server.id)
|
|
.bind(&payload.uuid)
|
|
.fetch_optional(&state.db)
|
|
.await?;
|
|
|
|
let balance = match bal_opt {
|
|
Some(b) => b,
|
|
None => {
|
|
let starting_balance = 1000.0;
|
|
sqlx::query(
|
|
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(server_id, uuid) DO UPDATE SET username = excluded.username",
|
|
)
|
|
.bind(server.id)
|
|
.bind(&payload.uuid)
|
|
.bind(&username)
|
|
.bind(starting_balance)
|
|
.bind(&now)
|
|
.execute(&state.db)
|
|
.await?;
|
|
starting_balance
|
|
}
|
|
};
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"server_id": server.id,
|
|
"uuid": payload.uuid,
|
|
"balance": balance,
|
|
"currency_symbol": "$"
|
|
})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ServerTransferPayload {
|
|
pub from_uuid: String,
|
|
pub from_name: String,
|
|
pub to_uuid: String,
|
|
pub to_name: String,
|
|
pub amount: f64,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
pub async fn server_transfer(
|
|
GameServer(server): GameServer,
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<ServerTransferPayload>,
|
|
) -> AppResult<Json<Value>> {
|
|
if !payload.amount.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<f64> = sqlx::query_scalar(
|
|
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
|
|
)
|
|
.bind(server.id)
|
|
.bind(&payload.from_uuid)
|
|
.fetch_optional(&mut *tx)
|
|
.await?;
|
|
|
|
let current_from_bal = from_bal.unwrap_or(0.0);
|
|
if current_from_bal < payload.amount {
|
|
return Err(AppError::bad_request("Insufficient funds"));
|
|
}
|
|
|
|
let new_from_bal = current_from_bal - payload.amount;
|
|
sqlx::query("UPDATE server_economy SET balance = ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
|
|
.bind(new_from_bal)
|
|
.bind(&now)
|
|
.bind(server.id)
|
|
.bind(&payload.from_uuid)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
// Update receiver
|
|
sqlx::query(
|
|
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT (server_id, uuid) DO UPDATE SET
|
|
balance = balance + excluded.balance,
|
|
username = excluded.username,
|
|
updated_at = excluded.updated_at",
|
|
)
|
|
.bind(server.id)
|
|
.bind(&payload.to_uuid)
|
|
.bind(&payload.to_name)
|
|
.bind(payload.amount)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
let desc = payload.description.unwrap_or_else(|| format!("Payment to {}", payload.to_name));
|
|
sqlx::query(
|
|
"INSERT INTO economy_transactions (server_id, from_uuid, from_name, to_uuid, to_name, amount, description, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(server.id)
|
|
.bind(&payload.from_uuid)
|
|
.bind(&payload.from_name)
|
|
.bind(&payload.to_uuid)
|
|
.bind(&payload.to_name)
|
|
.bind(payload.amount)
|
|
.bind(&desc)
|
|
.bind(&now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
tx.commit().await?;
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"ok": true,
|
|
"from_balance": new_from_bal
|
|
})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ServerSyncBalancePayload {
|
|
pub uuid: String,
|
|
pub username: String,
|
|
pub new_balance: f64,
|
|
}
|
|
|
|
pub async fn server_sync_balance(
|
|
GameServer(server): GameServer,
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<ServerSyncBalancePayload>,
|
|
) -> AppResult<Json<Value>> {
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
sqlx::query(
|
|
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(server_id, uuid) DO UPDATE SET
|
|
balance = excluded.balance,
|
|
username = excluded.username,
|
|
updated_at = excluded.updated_at",
|
|
)
|
|
.bind(server.id)
|
|
.bind(&payload.uuid)
|
|
.bind(&payload.username)
|
|
.bind(payload.new_balance)
|
|
.bind(&now)
|
|
.execute(&state.db)
|
|
.await?;
|
|
|
|
Ok(Json(serde_json::json!({ "ok": true })))
|
|
}
|
|
|
|
pub async fn server_baltop(
|
|
GameServer(server): GameServer,
|
|
State(state): State<AppState>,
|
|
) -> AppResult<Json<Vec<BaltopEntry>>> {
|
|
let rows: Vec<(String, String, f64)> = sqlx::query_as(
|
|
"SELECT uuid, username, balance
|
|
FROM server_economy
|
|
WHERE server_id = ?
|
|
ORDER BY balance DESC
|
|
LIMIT 20",
|
|
)
|
|
.bind(server.id)
|
|
.fetch_all(&state.db)
|
|
.await?;
|
|
|
|
let list = rows
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(i, (uuid, username, balance))| BaltopEntry {
|
|
rank: i + 1,
|
|
uuid,
|
|
username,
|
|
balance,
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(list))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct MarketListPayload {
|
|
pub operation_id: String,
|
|
pub item_data: Option<String>,
|
|
pub seller_uuid: String,
|
|
pub seller_name: String,
|
|
pub item_id: String,
|
|
pub item_name: String,
|
|
pub amount: i32,
|
|
pub price: f64,
|
|
}
|
|
|
|
pub async fn server_market_list(
|
|
GameServer(server): GameServer,
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<MarketListPayload>,
|
|
) -> AppResult<Json<Value>> {
|
|
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<AppState>,
|
|
Json(payload): Json<MarketBuyPayload>,
|
|
) -> AppResult<Json<Value>> {
|
|
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
|
|
if let Some(previous) = previous { return Ok(Json(previous)); }
|
|
let listing: Option<(String, String, String, String, i32, f64, Option<String>)> = 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
|
|
}
|