From e6c6073eae13ec71a56c895a45e4683c195031e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 17:57:58 +0000 Subject: [PATCH] Guild bank: in-game commands, guild market listings and purchases, shop sales to the bank, guild payments --- crates/shared/src/lib.rs | 3 + .../net/scopenet/paper/ScopenetPlugin.java | 5 +- .../paper/commands/EconomyHandler.java | 50 +++- .../paper/commands/EconomyOperations.java | 14 +- .../scopenet/paper/commands/GuildBank.java | 115 +++++++++ .../scopenet/paper/commands/GuildHandler.java | 17 +- launcher/src/components/GuildWallet.svelte | 23 +- panel/server/src/db.rs | 5 + panel/server/src/purge.rs | 2 + panel/server/src/routes/economy.rs | 131 +++++----- panel/server/src/routes/guild_bank.rs | 233 ++++++++++++++++++ panel/server/src/routes/guilds.rs | 4 +- panel/server/src/routes/mod.rs | 5 + panel/server/tests/guild_bank.rs | 155 ++++++++++++ 14 files changed, 680 insertions(+), 82 deletions(-) create mode 100644 integrations/paper/src/main/java/net/scopenet/paper/commands/GuildBank.java create mode 100644 panel/server/src/routes/guild_bank.rs create mode 100644 panel/server/tests/guild_bank.rs diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 77bf135..060e67d 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -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, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java index 2b32286..1cc8bb8 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java @@ -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); diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java index 9b6c56e..de609d4 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java @@ -35,6 +35,12 @@ public final class EconomyHandler implements CommandExecutor, Listener { // Trade sessions: maps player UUID to active trade session private final Map activeTrades = new ConcurrentHashMap<>(); + // Sell chests opened with /guild sell pay the guild bank. + private final Set 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> 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 (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 soldItems = new ArrayList<>(); List 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); } } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyOperations.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyOperations.java index 6950aad..3720ff1 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyOperations.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyOperations.java @@ -125,9 +125,17 @@ final class EconomyOperations { player.sendMessage(ChatColor.RED + "Transaction rejected; held items returned: " + job.get("error").getAsString()); } else { JsonObject result = job.getAsJsonObject("result"); - 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); + 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()); } } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildBank.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildBank.java new file mode 100644 index 0000000..bb9d768 --- /dev/null +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildBank.java @@ -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 ] */ + 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 ]"); + 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 , /guild sell, /guild market, /guild pay "); + } catch (Exception e) { + player.sendMessage(ChatColor.RED + "Could not load the guild bank: " + e.getMessage()); + } + }); + } + + /** /guild pay */ + void pay(Player player, String[] args) { + if (args.length < 2) { + player.sendMessage(ChatColor.RED + "Usage: /guild pay "); + 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; + } +} diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java index 6e908c5..1b99cd6 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java @@ -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 " + 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 ]" + 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 | buy <#id>" + ChatColor.GRAY + " - Trade on the market as a guild"); + player.sendMessage(ChatColor.YELLOW + "/guild pay " + ChatColor.GRAY + " - Pay another guild"); } private void handleClaim(Player player) { diff --git a/launcher/src/components/GuildWallet.svelte b/launcher/src/components/GuildWallet.svelte index 70b5aa8..0454fba 100644 --- a/launcher/src/components/GuildWallet.svelte +++ b/launcher/src/components/GuildWallet.svelte @@ -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 = { + 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 @@
{money(shown)}
-
Deposited{money(totals.in)}
-
Withdrawn{money(totals.out)}
+
Money in{money(totals.in)}
+
Money out{money(totals.out)}
Your wallet{wallet.my_balance != null ? money(wallet.my_balance) : '—'}

Totals cover the last {wallet.transactions.length} transfers.

@@ -203,15 +208,15 @@ {#each wallet.transactions as tx (tx.id)} {@const m = who(tx.actor_uuid)}
  • - - {#if tx.kind === 'withdraw'}{:else}{/if} + + {#if isOut(tx.kind)}{:else}{/if}
    {m?.name ?? 'Former member'} - {tx.kind === 'withdraw' ? 'withdrew' : 'deposited'} · {ago(tx.created_at)} + {verbs[tx.kind] ?? tx.kind}{tx.note ? ` · ${tx.note}` : ''} · {ago(tx.created_at)}
    - {tx.kind === 'withdraw' ? '−' : '+'}{money(tx.amount)} + {isOut(tx.kind) ? '−' : '+'}{money(tx.amount)}
  • {/each} diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs index c4f6329..8122e0c 100644 --- a/panel/server/src/db.rs +++ b/panel/server/src/db.rs @@ -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#" diff --git a/panel/server/src/purge.rs b/panel/server/src/purge.rs index d054d81..36b134d 100644 --- a/panel/server/src/purge.rs +++ b/panel/server/src/purge.rs @@ -114,6 +114,8 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult AppResult<(sqlx::Transaction<'static, sqlx::Sqlite>, Option)> { +pub(crate) async fn begin_operation(state: &AppState, server_id: i64, operation_id: &str) -> AppResult<(sqlx::Transaction<'static, sqlx::Sqlite>, Option)> { if operation_id.is_empty() || operation_id.len() > 100 { return Err(AppError::bad_request("Invalid operation ID")); } let mut tx = state.db.begin().await?; // The first statement obtains the write lock before reading any balances. @@ -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> { +pub(crate) async fn finish_operation(mut tx: sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, operation_id: &str, response: Value) -> AppResult> { sqlx::query("UPDATE economy_operations SET response = ? WHERE server_id = ? AND operation_id = ?") .bind(response.to_string()).bind(server_id).bind(operation_id).execute(&mut *tx).await?; tx.commit().await?; Ok(Json(response)) } -async fn ensure_balance(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, server_id: i64, uuid: &str, name: &str) -> AppResult<()> { +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) -> AppResult>> { - let rows: Vec<(i64, String, String, String, i32, f64, Option)> = sqlx::query_as("SELECT id, seller_name, item_id, item_name, amount, price, item_data FROM server_market WHERE server_id = ? ORDER BY id DESC LIMIT 45") + let rows: Vec<(i64, String, String, String, i32, f64, Option, Option)> = 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, State(state): State, ) -> AppResult>> { - let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String)> = sqlx::query_as( - "SELECT id, server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at - FROM server_market - WHERE server_id = ? - ORDER BY id DESC + let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String, Option)> = 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> { let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?; if let Some(previous) = previous { return Ok(Json(previous)); } - let listing: Option<(String, String, String, String, i32, f64, Option)> = sqlx::query_as( + let listing: Option<(String, String, String, String, i32, f64, Option, Option)> = sqlx::query_as( "DELETE FROM server_market WHERE id = ? AND server_id = ? - RETURNING seller_uuid, seller_name, item_id, item_name, amount, price, item_data + 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) diff --git a/panel/server/src/routes/guild_bank.rs b/panel/server/src/routes/guild_bank.rs new file mode 100644 index 0000000..225c7cf --- /dev/null +++ b/panel/server/src/routes/guild_bank.rs @@ -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> { + 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 { + ensure_wallet(conn, server_id, guild_id).await?; + let balance: Option = 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::()) + .bind(chrono::Utc::now().to_rfc3339()) + .execute(&mut *conn) + .await?; + Ok(()) +} + +async fn player_balance(conn: &mut SqliteConnection, server_id: i64, uuid: &str) -> AppResult { + 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, Json(p): Json) -> AppResult> { + 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::>(), + }))) +} + +#[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, Json(p): Json) -> AppResult> { + 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 = 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, Json(p): Json) -> AppResult> { + 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, Json(p): Json) -> AppResult> { + 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::(); + 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 +} diff --git a/panel/server/src/routes/guilds.rs b/panel/server/src/routes/guilds.rs index cc35911..136a942 100644 --- a/panel/server/src/routes/guilds.rs +++ b/panel/server/src/routes/guilds.rs @@ -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::>()}))) + 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::>()}))) } pub async fn guild_wallet_deposit(auth: AuthUser, Path(guild_id): Path, State(state): State, Json(p): Json) -> AppResult> { diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs index 22c25f5..8682078 100644 --- a/panel/server/src/routes/mod.rs +++ b/panel/server/src/routes/mod.rs @@ -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 { .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)) diff --git a/panel/server/tests/guild_bank.rs b/panel/server/tests/guild_bank.rs new file mode 100644 index 0000000..7a83005 --- /dev/null +++ b/panel/server/tests/guild_bank.rs @@ -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 = 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 = 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 = 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); +}