Guild bank: in-game commands, guild market listings and purchases, shop sales to the bank, guild payments
This commit is contained in:
14 files changed
+680
-82
No files matched your search
@@ -570,6 +570,11 @@ const MIGRATIONS: &[&str] = &[
|
||||
);
|
||||
CREATE INDEX quest_assignments_user ON quest_assignments(user_uuid, period_key, position);
|
||||
"#,
|
||||
// Guild bank: listings sold on behalf of a guild, and a note on each wallet movement.
|
||||
r#"
|
||||
ALTER TABLE server_market ADD COLUMN seller_guild_id TEXT;
|
||||
ALTER TABLE guild_wallet_transactions ADD COLUMN note TEXT NOT NULL DEFAULT '';
|
||||
"#,
|
||||
// Bumped by any change to claims, guilds or membership so game servers can
|
||||
// poll a single number instead of asking about every block.
|
||||
r#"
|
||||
|
||||
@@ -114,6 +114,8 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeRepo
|
||||
|
||||
// ---- economy ----
|
||||
report.add("economy", run(c, "DELETE FROM server_economy WHERE uuid = ?", &[uuid]).await?);
|
||||
// Items a guild had listed stay for the guild; only the lister is anonymised.
|
||||
run(c, "UPDATE server_market SET seller_uuid = ?, seller_name = ? WHERE seller_uuid = ? AND seller_guild_id IS NOT NULL", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
|
||||
report.add("economy", run(c, "DELETE FROM server_market WHERE seller_uuid = ?", &[uuid]).await?);
|
||||
run(c, "UPDATE economy_transactions SET from_uuid = ?, from_name = ? WHERE from_uuid = ?", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
|
||||
run(c, "UPDATE economy_transactions SET to_uuid = ?, to_name = ? WHERE to_uuid = ?", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
|
||||
|
||||
@@ -10,7 +10,7 @@ use scopenet_shared::{BaltopEntry, EconomyTransaction, MarketListing, ServerEcon
|
||||
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>)> {
|
||||
pub(crate) async fn begin_operation(state: &AppState, server_id: i64, operation_id: &str) -> AppResult<(sqlx::Transaction<'static, sqlx::Sqlite>, Option<Value>)> {
|
||||
if operation_id.is_empty() || operation_id.len() > 100 { return Err(AppError::bad_request("Invalid operation ID")); }
|
||||
let mut tx = state.db.begin().await?;
|
||||
// The first statement obtains the write lock before reading any balances.
|
||||
@@ -24,14 +24,14 @@ async fn begin_operation(state: &AppState, server_id: i64, operation_id: &str) -
|
||||
Ok((tx, previous))
|
||||
}
|
||||
|
||||
async fn finish_operation(mut tx: sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, operation_id: &str, response: Value) -> AppResult<Json<Value>> {
|
||||
pub(crate) async fn finish_operation(mut tx: sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, operation_id: &str, response: Value) -> AppResult<Json<Value>> {
|
||||
sqlx::query("UPDATE economy_operations SET response = ? WHERE server_id = ? AND operation_id = ?")
|
||||
.bind(response.to_string()).bind(server_id).bind(operation_id).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn ensure_balance(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, uuid: &str, name: &str) -> AppResult<()> {
|
||||
pub(crate) 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(())
|
||||
@@ -61,9 +61,11 @@ pub async fn server_adjust_balance(GameServer(server): GameServer, State(state):
|
||||
}
|
||||
|
||||
pub async fn server_market_read(GameServer(server): GameServer, State(state): State<AppState>) -> AppResult<Json<Vec<Value>>> {
|
||||
let rows: Vec<(i64, String, String, String, i32, f64, Option<String>)> = 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")
|
||||
let rows: Vec<(i64, String, String, String, i32, f64, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT m.id, m.seller_name, m.item_id, m.item_name, m.amount, m.price, m.item_data, g.tag
|
||||
FROM server_market m LEFT JOIN guilds g ON g.id = m.seller_guild_id WHERE m.server_id = ? ORDER BY m.id DESC LIMIT 45")
|
||||
.bind(server.id).fetch_all(&state.db).await?;
|
||||
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()))
|
||||
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()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -171,11 +173,11 @@ 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
|
||||
let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT m.id, m.server_id, m.seller_uuid, m.seller_name, m.item_id, m.item_name, m.amount, m.price, m.created_at, g.tag
|
||||
FROM server_market m LEFT JOIN guilds g ON g.id = m.seller_guild_id
|
||||
WHERE m.server_id = ?
|
||||
ORDER BY m.id DESC
|
||||
LIMIT 100",
|
||||
)
|
||||
.bind(server_id)
|
||||
@@ -184,7 +186,8 @@ pub async fn get_server_market(
|
||||
|
||||
let list = rows
|
||||
.into_iter()
|
||||
.map(|(id, sid, suuid, sname, iid, iname, amt, price, created)| MarketListing {
|
||||
.map(|(id, sid, suuid, sname, iid, iname, amt, price, created, guild_tag)| MarketListing {
|
||||
seller_guild_tag: guild_tag,
|
||||
id,
|
||||
server_id: sid,
|
||||
seller_uuid: suuid,
|
||||
@@ -411,6 +414,9 @@ pub struct MarketListPayload {
|
||||
pub item_name: String,
|
||||
pub amount: i32,
|
||||
pub price: f64,
|
||||
/// Sell on behalf of the seller's guild: the sale is paid into its bank.
|
||||
#[serde(default)]
|
||||
pub as_guild: bool,
|
||||
}
|
||||
|
||||
pub async fn server_market_list(
|
||||
@@ -422,9 +428,14 @@ pub async fn server_market_list(
|
||||
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 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")); }
|
||||
Some(m.guild_id)
|
||||
} 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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at, item_data, seller_guild_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(server.id)
|
||||
@@ -436,6 +447,7 @@ pub async fn server_market_list(
|
||||
.bind(payload.price)
|
||||
.bind(&now)
|
||||
.bind(&payload.item_data)
|
||||
.bind(&guild_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -448,6 +460,9 @@ pub struct MarketBuyPayload {
|
||||
pub listing_id: i64,
|
||||
pub buyer_uuid: String,
|
||||
pub buyer_name: String,
|
||||
/// Pay from the buyer's guild bank (leaders and officers) instead of their own balance.
|
||||
#[serde(default)]
|
||||
pub as_guild: bool,
|
||||
}
|
||||
|
||||
pub async fn server_market_buy(
|
||||
@@ -457,10 +472,10 @@ pub async fn server_market_buy(
|
||||
) -> 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(
|
||||
let listing: Option<(String, String, String, String, i32, f64, Option<String>, Option<String>)> = sqlx::query_as(
|
||||
"DELETE FROM server_market
|
||||
WHERE id = ? AND server_id = ?
|
||||
RETURNING seller_uuid, seller_name, item_id, item_name, amount, price, item_data
|
||||
RETURNING seller_uuid, seller_name, item_id, item_name, amount, price, item_data, seller_guild_id
|
||||
",
|
||||
)
|
||||
.bind(payload.listing_id)
|
||||
@@ -468,52 +483,52 @@ pub async fn server_market_buy(
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let Some((seller_uuid, seller_name, item_id, item_name, amount, price, item_data)) = listing else {
|
||||
let Some((seller_uuid, seller_name, item_id, item_name, amount, price, item_data, seller_guild)) = listing else {
|
||||
return Err(AppError::not_found("Listing not found"));
|
||||
};
|
||||
// Guild sales only pay the guild if it still exists; otherwise the lister is paid.
|
||||
let seller_guild: Option<(String, String)> = match seller_guild {
|
||||
Some(id) => sqlx::query_as("SELECT id, tag FROM guilds WHERE id = ?").bind(id).fetch_optional(&mut *tx).await?,
|
||||
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")); }
|
||||
Some(m)
|
||||
} else { None };
|
||||
|
||||
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);
|
||||
// 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?;
|
||||
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);
|
||||
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?;
|
||||
buyer_bal - price
|
||||
};
|
||||
|
||||
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?;
|
||||
let new_balance = buyer_bal - price;
|
||||
|
||||
// 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?;
|
||||
// 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?;
|
||||
(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?;
|
||||
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)
|
||||
.execute(&mut *tx).await?;
|
||||
(seller_uuid.clone(), seller_name.clone())
|
||||
};
|
||||
|
||||
// Delete listing
|
||||
sqlx::query("DELETE FROM server_market WHERE id = ?")
|
||||
@@ -528,10 +543,10 @@ pub async fn server_market_buy(
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(server.id)
|
||||
.bind(&payload.buyer_uuid)
|
||||
.bind(&payload.buyer_name)
|
||||
.bind(&seller_uuid)
|
||||
.bind(&seller_name)
|
||||
.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)
|
||||
.bind(&desc)
|
||||
.bind(&now)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! The guild bank as seen from the game server: check the balance, move
|
||||
//! money between a player and the guild, credit shop sales to the guild, and
|
||||
//! pay other guilds. Every mutating call is idempotent on `operation_id`.
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::routes::economy::{begin_operation, ensure_balance, finish_operation};
|
||||
use crate::routes::servers::{GameServer, ServerRow};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// A player's place in the guild that plays on this server's instance.
|
||||
pub struct Membership {
|
||||
pub guild_id: String,
|
||||
pub name: String,
|
||||
pub tag: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
impl Membership {
|
||||
pub fn can_spend(&self) -> bool {
|
||||
matches!(self.role.as_str(), "leader" | "officer")
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn membership(conn: &mut SqliteConnection, server: &ServerRow, uuid: &str) -> AppResult<Option<Membership>> {
|
||||
let row: 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 = ? AND g.instance_id = ?",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(&server.instance_id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
Ok(row.map(|(guild_id, name, tag, role)| Membership { guild_id, name, tag, role }))
|
||||
}
|
||||
|
||||
pub fn valid_amount(amount: f64) -> AppResult<()> {
|
||||
if !amount.is_finite() || !(0.01..=1e9).contains(&amount) || (amount * 100.0 - (amount * 100.0).round()).abs() > 1e-6 {
|
||||
return Err(AppError::bad_request("Amount must be between 0.01 and 1,000,000,000 with at most two decimals"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ensure_wallet(conn: &mut SqliteConnection, server_id: i64, guild_id: &str) -> AppResult<()> {
|
||||
sqlx::query("INSERT INTO guild_wallets(server_id, guild_id, balance, updated_at) VALUES (?, ?, 0, ?) ON CONFLICT(server_id, guild_id) DO NOTHING")
|
||||
.bind(server_id)
|
||||
.bind(guild_id)
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move `delta` into (positive) or out of (negative) a guild wallet. Fails if it would go below zero.
|
||||
pub async fn adjust_wallet(conn: &mut SqliteConnection, server_id: i64, guild_id: &str, delta: f64) -> AppResult<f64> {
|
||||
ensure_wallet(conn, server_id, guild_id).await?;
|
||||
let balance: Option<f64> = sqlx::query_scalar(
|
||||
"UPDATE guild_wallets SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND guild_id = ? AND balance + ? >= 0 RETURNING balance",
|
||||
)
|
||||
.bind(delta)
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.bind(server_id)
|
||||
.bind(guild_id)
|
||||
.bind(delta)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
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<()> {
|
||||
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)
|
||||
.bind(actor)
|
||||
.bind(kind)
|
||||
.bind(amount)
|
||||
.bind(note.chars().filter(|c| !c.is_control()).take(120).collect::<String>())
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn player_balance(conn: &mut SqliteConnection, server_id: i64, uuid: &str) -> AppResult<f64> {
|
||||
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(server_id).bind(uuid).fetch_optional(&mut *conn).await?.unwrap_or(0.0))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Who {
|
||||
pub uuid: String,
|
||||
}
|
||||
|
||||
/// Bank summary for `/guild bank`: who the player's guild is, their role, the balance and recent activity.
|
||||
pub async fn server_bank_info(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Who>) -> AppResult<Json<Value>> {
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let Some(m) = membership(&mut conn, &server, &p.uuid).await? else {
|
||||
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(&m.guild_id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
.unwrap_or(0.0);
|
||||
let recent: Vec<(String, String, f64, String, String)> = sqlx::query_as(
|
||||
"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(&m.guild_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"guild": { "id": m.guild_id, "name": m.name, "tag": m.tag },
|
||||
"role": m.role,
|
||||
"can_spend": m.can_spend(),
|
||||
"balance": balance,
|
||||
"my_balance": player_balance(&mut conn, server.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::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Transfer {
|
||||
pub operation_id: String,
|
||||
pub uuid: String,
|
||||
pub username: String,
|
||||
pub amount: f64,
|
||||
/// `deposit` (player → guild) or `withdraw` (guild → player, officers and leaders).
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
pub async fn server_bank_transfer(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Transfer>) -> AppResult<Json<Value>> {
|
||||
valid_amount(p.amount)?;
|
||||
let withdraw = match p.action.as_str() {
|
||||
"deposit" => false,
|
||||
"withdraw" => true,
|
||||
_ => return Err(AppError::bad_request("action must be deposit or withdraw")),
|
||||
};
|
||||
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
|
||||
if let Some(previous) = previous {
|
||||
return Ok(Json(previous));
|
||||
}
|
||||
let m = membership(&mut tx, &server, &p.uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
|
||||
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?;
|
||||
let (guild_delta, player_delta) = if withdraw { (-p.amount, p.amount) } else { (p.amount, -p.amount) };
|
||||
let moved: Option<f64> = 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(&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 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)]
|
||||
pub struct Credit {
|
||||
pub operation_id: String,
|
||||
pub uuid: String,
|
||||
pub username: String,
|
||||
pub amount: f64,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Shop sales made with `/guild sell`: the proceeds go to the guild bank instead of the seller.
|
||||
pub async fn server_bank_credit(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Credit>) -> AppResult<Json<Value>> {
|
||||
valid_amount(p.amount)?;
|
||||
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
|
||||
if let Some(previous) = previous {
|
||||
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 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?;
|
||||
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
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Pay {
|
||||
pub operation_id: String,
|
||||
pub uuid: String,
|
||||
/// Tag of the receiving guild.
|
||||
pub to_tag: String,
|
||||
pub amount: f64,
|
||||
#[serde(default)]
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// Guild-to-guild payment (leaders and officers).
|
||||
pub async fn server_bank_pay(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Pay>) -> AppResult<Json<Value>> {
|
||||
valid_amount(p.amount)?;
|
||||
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
|
||||
if let Some(previous) = previous {
|
||||
return Ok(Json(previous));
|
||||
}
|
||||
let m = membership(&mut tx, &server, &p.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 target: Option<(String, String, String)> =
|
||||
sqlx::query_as("SELECT id, name, tag FROM guilds WHERE instance_id = ? AND tag = ? COLLATE NOCASE AND id <> ?")
|
||||
.bind(&server.instance_id)
|
||||
.bind(p.to_tag.trim())
|
||||
.bind(&m.guild_id)
|
||||
.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 note = p.note.chars().take(60).collect::<String>();
|
||||
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?;
|
||||
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
|
||||
}
|
||||
@@ -150,14 +150,14 @@ pub async fn guild_wallet(
|
||||
if !member { return Err(AppError::forbidden("Guild membership is required")); }
|
||||
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);
|
||||
let rows: Vec<(i64, String, String, f64, String)> = sqlx::query_as("SELECT id,actor_uuid,kind,amount,created_at FROM guild_wallet_transactions WHERE guild_id=? AND server_id=? ORDER BY id DESC LIMIT 30")
|
||||
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?;
|
||||
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);
|
||||
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)| serde_json::json!({"id":id,"actor_uuid":actor,"kind":kind,"amount":amount,"created_at":created_at})).collect::<Vec<_>>()})))
|
||||
Ok(Json(serde_json::json!({"balance":balance,"role":role,"my_balance":my_balance,"currency_symbol":"$","transactions":rows.into_iter().map(|(id,actor,kind,amount,created_at,note)| serde_json::json!({"id":id,"actor_uuid":actor,"kind":kind,"amount":amount,"created_at":created_at,"note":note})).collect::<Vec<_>>()})))
|
||||
}
|
||||
|
||||
pub async fn guild_wallet_deposit(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<GuildWalletTransfer>) -> AppResult<Json<Value>> {
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod activity;
|
||||
pub mod admin;
|
||||
pub mod connections;
|
||||
pub mod economy;
|
||||
pub mod guild_bank;
|
||||
pub mod guilds;
|
||||
pub mod landing;
|
||||
pub mod livemap;
|
||||
@@ -181,6 +182,10 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/guilds/player", post(guilds::server_get_player_guild))
|
||||
.route("/guilds/create", post(guilds::server_create_guild))
|
||||
.route("/guilds/leave", post(guilds::server_guild_leave))
|
||||
.route("/guilds/bank", post(guild_bank::server_bank_info))
|
||||
.route("/guilds/bank/transfer", post(guild_bank::server_bank_transfer))
|
||||
.route("/guilds/bank/credit", post(guild_bank::server_bank_credit))
|
||||
.route("/guilds/bank/pay", post(guild_bank::server_bank_pay))
|
||||
.route("/economy/balance", post(economy::server_get_balance))
|
||||
.route("/economy/pay", post(economy::server_transfer))
|
||||
.route("/economy/transfer", post(economy::server_transfer))
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Guild bank: deposits, officer-only spending, shop sales, guild-to-guild
|
||||
//! payments and guild market trades, all idempotent on their operation id.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
struct Fixture {
|
||||
t: TestApp,
|
||||
server: String,
|
||||
sid: i64,
|
||||
alex: String,
|
||||
steve: String,
|
||||
alex_uuid: String,
|
||||
steve_uuid: String,
|
||||
gid: String,
|
||||
}
|
||||
|
||||
async fn fixture() -> Fixture {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
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_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();
|
||||
// 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 }
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
async fn post(&self, path: &str, body: Value) -> (StatusCode, Value) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bank_deposits_withdrawals_and_roles() {
|
||||
let f = fixture().await;
|
||||
let (s, info) = f.post("guilds/bank", json!({"uuid": f.alex_uuid})).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!((info["guild"]["tag"].as_str(), info["role"].as_str(), info["balance"].as_f64()), (Some("IRON"), Some("leader"), Some(0.0)));
|
||||
assert_eq!(f.post("guilds/bank", json!({"uuid": "00000000-0000-0000-0000-000000000001"})).await.1["guild"], Value::Null);
|
||||
|
||||
// Members can deposit from their own balance (new accounts start with 1000).
|
||||
let dep = json!({"operation_id": "op-1", "uuid": f.steve_uuid, "username": "Steve", "amount": 250.5, "action": "deposit"});
|
||||
let (s, r) = f.post("guilds/bank/transfer", dep.clone()).await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
assert_eq!((r["balance"].as_f64(), r["my_balance"].as_f64()), (Some(250.5), Some(749.5)));
|
||||
// A retried request returns the same answer and moves no money twice.
|
||||
let (_, again) = f.post("guilds/bank/transfer", dep).await;
|
||||
assert_eq!(again, r);
|
||||
assert_eq!(f.guild_balance().await, 250.5);
|
||||
|
||||
// Only officers and leaders withdraw.
|
||||
let wd = |op: &str, who: &str, name: &str, amount: f64| json!({"operation_id": op, "uuid": who, "username": name, "amount": amount, "action": "withdraw"});
|
||||
assert_eq!(f.post("guilds/bank/transfer", wd("op-2", &f.steve_uuid, "Steve", 10.0)).await.0, StatusCode::FORBIDDEN);
|
||||
let (s, r) = f.post("guilds/bank/transfer", wd("op-3", &f.alex_uuid, "Alex", 100.0)).await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
assert_eq!(r["balance"], 150.5);
|
||||
// Not more than the bank holds, not more than a player holds, only valid amounts.
|
||||
assert_eq!(f.post("guilds/bank/transfer", wd("op-4", &f.alex_uuid, "Alex", 9999.0)).await.0, StatusCode::BAD_REQUEST);
|
||||
let big = json!({"operation_id": "op-5", "uuid": f.steve_uuid, "username": "Steve", "amount": 5000.0, "action": "deposit"});
|
||||
assert_eq!(f.post("guilds/bank/transfer", big).await.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(f.post("guilds/bank/transfer", wd("op-6", &f.alex_uuid, "Alex", 0.001)).await.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(f.guild_balance().await, 150.5);
|
||||
|
||||
// The launcher sees the same history, with who did it.
|
||||
let (s, w) = f.t.call("GET", &format!("/api/v1/guilds/{}/wallet?server_id={}", f.gid, f.sid), Some(&f.steve), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{w}");
|
||||
assert_eq!(w["balance"], 150.5);
|
||||
assert_eq!(w["transactions"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(w["role"], "member");
|
||||
let (_, w) = f.t.call("GET", &format!("/api/v1/guilds/{}/wallet?server_id={}", f.gid, f.sid), Some(&f.alex), None).await;
|
||||
assert_eq!(w["role"], "leader");
|
||||
}
|
||||
|
||||
#[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 (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;
|
||||
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});
|
||||
assert_eq!(f.post("guilds/bank/pay", pay("p1", &f.steve_uuid, "VOID", 10.0)).await.0, StatusCode::FORBIDDEN, "members can't spend");
|
||||
assert_eq!(f.post("guilds/bank/pay", pay("p2", &f.alex_uuid, "IRON", 10.0)).await.0, StatusCode::NOT_FOUND, "not to yourself");
|
||||
assert_eq!(f.post("guilds/bank/pay", pay("p3", &f.alex_uuid, "NOPE", 10.0)).await.0, StatusCode::NOT_FOUND);
|
||||
assert_eq!(f.post("guilds/bank/pay", pay("p4", &f.alex_uuid, "void", 500.0)).await.0, StatusCode::BAD_REQUEST, "insufficient");
|
||||
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();
|
||||
assert_eq!(void_balance, 24.0);
|
||||
let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM guild_wallet_transactions ORDER BY id").fetch_all(&f.t.db).await.unwrap();
|
||||
assert_eq!(kinds, ["sale", "transfer_out", "transfer_in"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guild_market_listings_and_purchases() {
|
||||
let f = fixture().await;
|
||||
let list = |op: &str, who: &str, name: &str, guild: bool| json!({"operation_id": op, "seller_uuid": who, "seller_name": name, "item_id": "DIAMOND", "item_name": "Diamond", "amount": 4, "price": 120.0, "as_guild": guild});
|
||||
// Only officers list for the guild.
|
||||
assert_eq!(f.post("economy/market/list", list("l1", &f.steve_uuid, "Steve", true)).await.0, StatusCode::FORBIDDEN);
|
||||
let (s, l) = f.post("economy/market/list", list("l2", &f.alex_uuid, "Alex", true)).await;
|
||||
assert_eq!(s, StatusCode::OK, "{l}");
|
||||
let listing = l["id"].as_i64().unwrap();
|
||||
let (_, read) = f.post("economy/market", json!({})).await;
|
||||
assert_eq!(read[0]["seller_guild"], "IRON");
|
||||
let (_, public) = f.t.call("GET", &format!("/api/v1/servers/{}/economy/market", f.sid), None, None).await;
|
||||
assert_eq!(public[0]["seller_guild_tag"], "IRON");
|
||||
|
||||
// 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();
|
||||
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();
|
||||
assert_eq!(still, 1, "a failed purchase leaves the listing in place");
|
||||
sqlx::query("UPDATE guild_wallets SET balance = 500 WHERE guild_id <> ?").bind(&f.gid).execute(&f.t.db).await.unwrap();
|
||||
let (s, r) = f.post("economy/market/buy", buy("b2")).await;
|
||||
assert_eq!(s, StatusCode::OK, "{r}");
|
||||
assert_eq!(r["new_balance"], 380.0);
|
||||
assert_eq!(f.guild_balance().await, 120.0, "the sale is paid into the listing guild's bank");
|
||||
let alex_balance: Option<f64> = sqlx::query_scalar("SELECT balance FROM server_economy WHERE uuid = ?").bind(&f.alex_uuid).fetch_optional(&f.t.db).await.unwrap();
|
||||
assert!(alex_balance.is_none(), "the lister is not paid personally");
|
||||
let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM guild_wallet_transactions ORDER BY id").fetch_all(&f.t.db).await.unwrap();
|
||||
assert_eq!(kinds, ["purchase", "sale"]);
|
||||
// Retried purchase is the same answer and charges nothing more.
|
||||
let (_, again) = f.post("economy/market/buy", buy("b2")).await;
|
||||
assert_eq!(again["new_balance"], 380.0);
|
||||
|
||||
// Plain player listings still pay the player.
|
||||
let (_, l2) = f.post("economy/market/list", list("l3", &f.steve_uuid, "Steve", false)).await;
|
||||
let pay = json!({"operation_id": "b3", "listing_id": l2["id"], "buyer_uuid": f.alex_uuid, "buyer_name": "Alex"});
|
||||
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();
|
||||
assert_eq!(steve_balance, 1120.0);
|
||||
}
|
||||
Reference in new issue
Block a user