Guild bank: in-game commands, guild market listings and purchases, shop sales to the bank, guild payments

This commit is contained in:
Claude committed 2026-09-30 17:57:58 +00:00
1 parent 5defda1128
commit e6c6073eae
14 files changed
+673 -75

No files matched your search

+3
View File
@@ -578,6 +578,9 @@ pub struct MarketListing {
pub amount: i32,
pub price: f64,
pub created_at: String,
/// Set when a guild is selling; the sale is paid into its bank.
#[serde(default)]
pub seller_guild_tag: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
@@ -16,6 +16,7 @@ import java.util.UUID;
public final class ScopenetPlugin extends JavaPlugin implements Listener {
private Integration integration;
private EconomyHandler economy;
@Override public void onEnable() {
// Install the deny-by-default listener even if configuration is invalid.
@@ -59,7 +60,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
// Register Economy commands & GUI
{
EconomyHandler economy = new EconomyHandler(this, integration);
economy = new EconomyHandler(this, integration);
getServer().getPluginManager().registerEvents(economy, this);
String[] econCmds = {"balance", "pay", "baltop", "shop", "sell", "market", "orders", "trade", "transactions"};
for (String c : econCmds) bindCommand(c, economy);
@@ -67,7 +68,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
// Register Guilds & Land Claims commands & GUI
{
GuildHandler guilds = new GuildHandler(this, integration);
GuildHandler guilds = new GuildHandler(this, integration, economy);
getServer().getPluginManager().registerEvents(guilds, this);
bindCommand("guild", guilds);
bindCommand("claim", guilds);
@@ -35,6 +35,12 @@ public final class EconomyHandler implements CommandExecutor, Listener {
// Trade sessions: maps player UUID to active trade session
private final Map<UUID, TradeSession> activeTrades = new ConcurrentHashMap<>();
// Sell chests opened with /guild sell pay the guild bank.
private final Set<Inventory> guildSellChests = Collections.newSetFromMap(new IdentityHashMap<>());
/** Durable, idempotent panel requests; also used by the guild bank. */
EconomyOperations operations() { return operations; }
// Each open inventory must retain its own snapshot of listing IDs.
private final Map<Inventory, Map<Integer, Long>> activeMarketListings = new IdentityHashMap<>();
@@ -216,6 +222,11 @@ public final class EconomyHandler implements CommandExecutor, Listener {
}
private void handleSell(Player player, String[] args) {
handleSell(player, args, false);
}
/** {@code guild}: the proceeds go to the player's guild bank instead of their balance. */
void handleSell(Player player, String[] args, boolean guild) {
if (args.length > 0 && args[0].equalsIgnoreCase("hand")) {
ItemStack hand = player.getInventory().getItemInMainHand();
if (hand.getType().isAir()) {
@@ -233,20 +244,41 @@ public final class EconomyHandler implements CommandExecutor, Listener {
JsonObject payload = new JsonObject();
payload.addProperty("uuid", player.getUniqueId().toString());
payload.addProperty("username", player.getName());
payload.addProperty("delta", total);
payload.addProperty(guild ? "amount" : "delta", total);
payload.addProperty("description", "Sell hand: " + sold.getType().name());
player.getInventory().setItemInMainHand(null);
if (!operations.enqueue(player, "economy/adjust", payload, List.of(sold), null)) giveOrDrop(player, sold);
if (!operations.enqueue(player, guild ? "guilds/bank/credit" : "economy/adjust", payload, List.of(sold), null)) giveOrDrop(player, sold);
return;
}
// Open sell chest GUI
Inventory sellInv = Bukkit.createInventory(null, 54, GUI_SELL_TITLE);
if (guild) guildSellChests.add(sellInv);
player.openInventory(sellInv);
player.sendMessage(ChatColor.YELLOW + "Place items to sell in the chest. Close the chest to confirm your sale!");
player.sendMessage(ChatColor.YELLOW + "Place items to sell in the chest. Close the chest to confirm your sale!"
+ (guild ? ChatColor.GOLD + " The money goes to your guild bank." : ""));
}
private void handleMarket(Player player, String[] args) {
handleMarket(player, args, false);
}
/** {@code asGuild}: list on behalf of the player's guild, or buy with its bank (see /guild market). */
void handleMarket(Player player, String[] args, boolean asGuild) {
if (asGuild && args.length >= 2 && args[0].equalsIgnoreCase("buy")) {
long listing;
try { listing = Long.parseLong(args[1].replace("#", "")); } catch (NumberFormatException e) {
player.sendMessage(ChatColor.RED + "Usage: /guild market buy <listing number> (shown as Listing #n in /market)");
return;
}
JsonObject payload = new JsonObject();
payload.addProperty("listing_id", listing);
payload.addProperty("buyer_uuid", player.getUniqueId().toString());
payload.addProperty("buyer_name", player.getName());
payload.addProperty("as_guild", true);
operations.enqueue(player, "economy/market/buy", payload, Collections.emptyList(), null);
return;
}
if (args.length >= 2 && args[0].equalsIgnoreCase("sell")) {
ItemStack hand = player.getInventory().getItemInMainHand();
if (hand.getType().isAir()) {
@@ -275,6 +307,7 @@ public final class EconomyHandler implements CommandExecutor, Listener {
payload.addProperty("amount", amount);
payload.addProperty("price", price);
payload.addProperty("item_data", EconomyOperations.encode(listed));
if (asGuild) payload.addProperty("as_guild", true);
// Escrow the exact stack before persisting a retryable operation.
player.getInventory().setItemInMainHand(null);
@@ -314,6 +347,9 @@ public final class EconomyHandler implements CommandExecutor, Listener {
JsonObject listing = el.getAsJsonObject();
long id = listing.has("id") ? listing.get("id").getAsLong() : -1;
String sellerName = listing.has("seller_name") ? listing.get("seller_name").getAsString() : "Unknown";
// Guild listings show the guild, and are paid into its bank.
String guildTag = listing.has("seller_guild") && listing.get("seller_guild").isJsonPrimitive()
? listing.get("seller_guild").getAsString() : null;
String listItemId = listing.has("item_id") ? listing.get("item_id").getAsString() : "STONE";
String listItemName = listing.has("item_name") ? listing.get("item_name").getAsString() : listItemId;
int listAmt = listing.has("amount") ? listing.get("amount").getAsInt() : 1;
@@ -323,8 +359,9 @@ public final class EconomyHandler implements CommandExecutor, Listener {
inventoryListings.put(slot, id);
inv.setItem(slot, GuiHelper.createItem(mat, listAmt,
"&f" + listItemName + " &7(x" + listAmt + ")",
"&7Seller: &f" + sellerName,
guildTag != null ? "&7Seller: &6[" + guildTag + "] &fGuild" : "&7Seller: &f" + sellerName,
"&7Price: &a$" + String.format(Locale.US, "%,.2f", listPrice),
"&8Listing #" + id,
"",
"&eClick to Buy!"));
slot++;
@@ -447,6 +484,7 @@ public final class EconomyHandler implements CommandExecutor, Listener {
if (title.equals(GUI_SELL_TITLE)) {
// Move sellable items to durable escrow before crediting the balance.
Inventory inv = event.getInventory();
boolean toGuild = guildSellChests.remove(inv);
List<ItemStack> soldItems = new ArrayList<>();
List<ItemStack> unsellable = new ArrayList<>();
double totalEarned = 0;
@@ -472,9 +510,9 @@ public final class EconomyHandler implements CommandExecutor, Listener {
JsonObject payload = new JsonObject();
payload.addProperty("uuid", player.getUniqueId().toString());
payload.addProperty("username", player.getName());
payload.addProperty("delta", totalEarned);
payload.addProperty(toGuild ? "amount" : "delta", totalEarned);
payload.addProperty("description", "Sell chest: " + countSold + " items");
if (!operations.enqueue(player, "economy/adjust", payload, soldItems, null)) {
if (!operations.enqueue(player, toGuild ? "guilds/bank/credit" : "economy/adjust", payload, soldItems, null)) {
for (ItemStack item : soldItems) giveOrDrop(player, item);
}
}
@@ -125,10 +125,18 @@ final class EconomyOperations {
player.sendMessage(ChatColor.RED + "Transaction rejected; held items returned: " + job.get("error").getAsString());
} else {
JsonObject result = job.getAsJsonObject("result");
if (result.has("message") && result.get("message").isJsonPrimitive()) {
// Guild bank operations explain themselves ("Deposited $50.00 into [IRON] bank.").
JsonElement balanceValue = result.get("balance");
String bank = balanceValue != null && balanceValue.isJsonPrimitive()
? " Bank: $" + String.format(Locale.US, "%,.2f", balanceValue.getAsDouble()) : "";
player.sendMessage(ChatColor.GREEN + result.get("message").getAsString() + bank);
} else {
JsonElement balanceValue = result.has("new_balance") ? result.get("new_balance") : result.get("balance");
String balance = balanceValue != null ? " Balance: $" + String.format(Locale.US, "%,.2f", balanceValue.getAsDouble()) : "";
player.sendMessage(ChatColor.GREEN + "Transaction completed." + balance);
}
}
} catch (Exception e) { plugin.getLogger().severe("Economy delivery requires attention for " + id + ": " + e.getMessage()); }
}
}
@@ -0,0 +1,115 @@
package net.scopenet.paper.commands;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.scopenet.integration.Integration;
import net.scopenet.paper.ScopenetPlugin;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import java.util.Collections;
import java.util.Locale;
/**
* /guild bank, /guild pay: the guild's money as seen in game. Balances are
* kept by the panel (and shown in the launcher); spending rights are checked
* there too, so the plugin only has to pass on who is asking.
*/
final class GuildBank {
private final ScopenetPlugin plugin;
private final Integration integration;
private final EconomyOperations operations;
GuildBank(ScopenetPlugin plugin, Integration integration, EconomyOperations operations) {
this.plugin = plugin;
this.integration = integration;
this.operations = operations;
}
private static String money(double value) {
return "$" + String.format(Locale.US, "%,.2f", value);
}
private static Double amount(Player player, String text) {
try {
double value = Double.parseDouble(text.replace(",", ""));
if (Double.isNaN(value) || Double.isInfinite(value) || value < 0.01) throw new NumberFormatException();
return Math.round(value * 100.0) / 100.0;
} catch (NumberFormatException e) {
player.sendMessage(ChatColor.RED + "Enter an amount of at least 0.01, for example 25 or 12.50.");
return null;
}
}
/** /guild bank [deposit|withdraw <amount>] */
void bank(Player player, String[] args) {
if (args.length >= 2 && (args[0].equalsIgnoreCase("deposit") || args[0].equalsIgnoreCase("withdraw"))) {
String action = args[0].toLowerCase();
if (!player.hasPermission("scopenet.command.guild.bank." + action)) {
player.sendMessage(ChatColor.RED + "You do not have permission to " + action + " guild money.");
return;
}
Double value = amount(player, args[1]);
if (value == null) return;
JsonObject payload = new JsonObject();
payload.addProperty("uuid", player.getUniqueId().toString());
payload.addProperty("username", player.getName());
payload.addProperty("amount", value);
payload.addProperty("action", action);
operations.enqueue(player, "guilds/bank/transfer", payload, Collections.emptyList(), null);
return;
}
if (args.length > 0 && !args[0].equalsIgnoreCase("balance") && !args[0].equalsIgnoreCase("info")) {
player.sendMessage(ChatColor.RED + "Usage: /guild bank [deposit|withdraw <amount>]");
return;
}
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
JsonObject info = integration.client().post("guilds/bank", who(player));
if (!info.has("guild") || info.get("guild").isJsonNull()) {
player.sendMessage(ChatColor.RED + "You are not in a guild on this server.");
return;
}
JsonObject guild = info.getAsJsonObject("guild");
player.sendMessage(ChatColor.GOLD + "=== [" + guild.get("tag").getAsString() + "] " + guild.get("name").getAsString() + " Bank ===");
player.sendMessage(ChatColor.YELLOW + "Balance: " + ChatColor.GREEN + money(info.get("balance").getAsDouble())
+ ChatColor.GRAY + " Your wallet: " + money(info.get("my_balance").getAsDouble())
+ " Role: " + info.get("role").getAsString());
JsonArray recent = info.getAsJsonArray("recent");
for (JsonElement element : recent) {
JsonObject row = element.getAsJsonObject();
String kind = row.get("kind").getAsString();
boolean out = kind.equals("withdraw") || kind.equals("purchase") || kind.equals("transfer_out");
player.sendMessage(ChatColor.GRAY + " " + (out ? ChatColor.RED + "-" : ChatColor.GREEN + "+")
+ money(row.get("amount").getAsDouble()) + ChatColor.GRAY + " " + kind.replace('_', ' ') + " by "
+ row.get("who").getAsString());
}
player.sendMessage(ChatColor.GRAY + "/guild bank deposit|withdraw <amount>, /guild sell, /guild market, /guild pay <tag> <amount>");
} catch (Exception e) {
player.sendMessage(ChatColor.RED + "Could not load the guild bank: " + e.getMessage());
}
});
}
/** /guild pay <tag> <amount> */
void pay(Player player, String[] args) {
if (args.length < 2) {
player.sendMessage(ChatColor.RED + "Usage: /guild pay <guild tag> <amount>");
return;
}
Double value = amount(player, args[1]);
if (value == null) return;
JsonObject payload = new JsonObject();
payload.addProperty("uuid", player.getUniqueId().toString());
payload.addProperty("to_tag", args[0]);
payload.addProperty("amount", value);
operations.enqueue(player, "guilds/bank/pay", payload, Collections.emptyList(), null);
}
private static JsonObject who(Player player) {
JsonObject body = new JsonObject();
body.addProperty("uuid", player.getUniqueId().toString());
return body;
}
}
@@ -37,9 +37,14 @@ public final class GuildHandler implements CommandExecutor, Listener {
private static final String GUI_GUILD_TITLE = ChatColor.DARK_AQUA + "Guild Dashboard";
public GuildHandler(ScopenetPlugin plugin, Integration integration) {
private final EconomyHandler economy;
private final GuildBank bank;
public GuildHandler(ScopenetPlugin plugin, Integration integration, EconomyHandler economy) {
this.plugin = plugin;
this.integration = integration;
this.economy = economy;
this.bank = new GuildBank(plugin, integration, economy.operations());
this.guildHomesFile = new File(plugin.getDataFolder(), "guild_homes.yml");
this.guildHomesConfig = YamlConfiguration.loadConfiguration(guildHomesFile);
loadGuildHomes();
@@ -90,7 +95,7 @@ public final class GuildHandler implements CommandExecutor, Listener {
String sub = args[0].toLowerCase();
String permission = sub.equals("c") ? "chat" : sub;
if (java.util.Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members").contains(permission)
if (java.util.Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay").contains(permission)
&& !player.hasPermission("scopenet.command.guild." + permission)) {
player.sendMessage(ChatColor.RED + "You do not have permission to use /guild " + permission + ".");
return true;
@@ -105,6 +110,10 @@ public final class GuildHandler implements CommandExecutor, Listener {
case "sethome" -> handleSetHome(player);
case "home" -> handleHome(player);
case "members" -> handleMembers(player);
case "bank" -> bank.bank(player, Arrays.copyOfRange(args, 1, args.length));
case "pay" -> bank.pay(player, Arrays.copyOfRange(args, 1, args.length));
case "sell" -> economy.handleSell(player, Arrays.copyOfRange(args, 1, args.length), true);
case "market" -> economy.handleMarket(player, Arrays.copyOfRange(args, 1, args.length), true);
default -> sendGuildHelp(player);
}
return true;
@@ -123,6 +132,10 @@ public final class GuildHandler implements CommandExecutor, Listener {
player.sendMessage(ChatColor.YELLOW + "/guild map" + ChatColor.GRAY + " - Show nearby land claims radar map");
player.sendMessage(ChatColor.YELLOW + "/guild chat <msg>" + ChatColor.GRAY + " - Send message to guild members");
player.sendMessage(ChatColor.YELLOW + "/guild sethome / /guild home" + ChatColor.GRAY + " - Guild waypoint base");
player.sendMessage(ChatColor.YELLOW + "/guild bank [deposit|withdraw <amount>]" + ChatColor.GRAY + " - Guild money");
player.sendMessage(ChatColor.YELLOW + "/guild sell [hand]" + ChatColor.GRAY + " - Sell items to the shop for the guild bank");
player.sendMessage(ChatColor.YELLOW + "/guild market sell <price> | buy <#id>" + ChatColor.GRAY + " - Trade on the market as a guild");
player.sendMessage(ChatColor.YELLOW + "/guild pay <tag> <amount>" + ChatColor.GRAY + " - Pay another guild");
}
private void handleClaim(Player player) {
+14 -9
View File
@@ -8,7 +8,7 @@
import { toast } from '../lib/store.svelte';
import type { Guild, GuildMember } from '../lib/types';
type Tx = { id: number; actor_uuid: string; kind: 'deposit' | 'withdraw'; amount: number; created_at: string };
type Tx = { id: number; actor_uuid: string; kind: string; amount: number; created_at: string; note?: string };
type WalletData = { balance: number; my_balance?: number; role?: string; currency_symbol?: string; transactions: Tx[] };
let { guild, members, servers, instanceId }: {
@@ -36,9 +36,14 @@
const overLimit = $derived(validAmount && limit != null && value > limit + 1e-9);
const afterGuild = $derived(wallet && validAmount ? wallet.balance + (mode === 'deposit' ? value : -value) : null);
const afterMine = $derived(wallet?.my_balance != null && validAmount ? wallet.my_balance + (mode === 'deposit' ? -value : value) : null);
// Money the guild receives (deposits, shop and market sales, payments from guilds) vs. spends.
const isOut = (kind: string) => kind === 'withdraw' || kind === 'purchase' || kind === 'transfer_out';
const verbs: Record<string, string> = {
deposit: 'deposited', withdraw: 'withdrew', sale: 'sold items', purchase: 'bought', transfer_in: 'received a payment', transfer_out: 'paid a guild'
};
const totals = $derived({
in: wallet?.transactions.filter((t) => t.kind === 'deposit').reduce((n, t) => n + t.amount, 0) ?? 0,
out: wallet?.transactions.filter((t) => t.kind === 'withdraw').reduce((n, t) => n + t.amount, 0) ?? 0
in: wallet?.transactions.filter((t) => !isOut(t.kind)).reduce((n, t) => n + t.amount, 0) ?? 0,
out: wallet?.transactions.filter((t) => isOut(t.kind)).reduce((n, t) => n + t.amount, 0) ?? 0
});
const quick = [10, 50, 100, 500, 1000];
@@ -136,8 +141,8 @@
</div>
<div class="amount">{money(shown)}</div>
<div class="t-stats">
<div><span class="k"><TrendingUp size={12} /> Deposited</span><strong class="in">{money(totals.in)}</strong></div>
<div><span class="k"><ArrowUpFromLine size={12} /> Withdrawn</span><strong class="out">{money(totals.out)}</strong></div>
<div><span class="k"><TrendingUp size={12} /> Money in</span><strong class="in">{money(totals.in)}</strong></div>
<div><span class="k"><ArrowUpFromLine size={12} /> Money out</span><strong class="out">{money(totals.out)}</strong></div>
<div><span class="k"><Wallet size={12} /> Your wallet</span><strong>{wallet.my_balance != null ? money(wallet.my_balance) : '—'}</strong></div>
</div>
<p class="t-note tiny">Totals cover the last {wallet.transactions.length} transfers.</p>
@@ -203,15 +208,15 @@
{#each wallet.transactions as tx (tx.id)}
{@const m = who(tx.actor_uuid)}
<li>
<span class="ic" class:out={tx.kind === 'withdraw'}>
{#if tx.kind === 'withdraw'}<ArrowUpFromLine size={14} />{:else}<ArrowDownToLine size={14} />{/if}
<span class="ic" class:out={isOut(tx.kind)}>
{#if isOut(tx.kind)}<ArrowUpFromLine size={14} />{:else}<ArrowDownToLine size={14} />{/if}
</span>
<Avatar uuid={tx.actor_uuid} name={m?.name ?? '?'} size={1.7} />
<div class="who">
<strong>{m?.name ?? 'Former member'}</strong>
<span class="tiny muted">{tx.kind === 'withdraw' ? 'withdrew' : 'deposited'} · {ago(tx.created_at)}</span>
<span class="tiny muted" title={tx.note}>{verbs[tx.kind] ?? tx.kind}{tx.note ? ` · ${tx.note}` : ''} · {ago(tx.created_at)}</span>
</div>
<span class="amt" class:out={tx.kind === 'withdraw'}>{tx.kind === 'withdraw' ? '−' : '+'}{money(tx.amount)}</span>
<span class="amt" class:out={isOut(tx.kind)}>{isOut(tx.kind) ? '−' : '+'}{money(tx.amount)}</span>
</li>
{/each}
</ul>
+5
View File
@@ -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#"
+2
View File
@@ -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?;
+69 -54
View File
@@ -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?;
let new_balance = buyer_bal - price;
.bind(price).bind(&now).bind(server.id).bind(&payload.buyer_uuid).execute(&mut *tx).await?;
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)
+233
View File
@@ -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
}
+2 -2
View File
@@ -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>> {
+5
View File
@@ -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))
+155
View File
@@ -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);
}