Guild bank: in-game commands, guild market listings and purchases, shop sales to the bank, guild payments
This commit is contained in:
14 files changed
+673
-75
No files matched your search
@@ -578,6 +578,9 @@ pub struct MarketListing {
|
|||||||
pub amount: i32,
|
pub amount: i32,
|
||||||
pub price: f64,
|
pub price: f64,
|
||||||
pub created_at: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||||
private Integration integration;
|
private Integration integration;
|
||||||
|
private EconomyHandler economy;
|
||||||
|
|
||||||
@Override public void onEnable() {
|
@Override public void onEnable() {
|
||||||
// Install the deny-by-default listener even if configuration is invalid.
|
// 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
|
// Register Economy commands & GUI
|
||||||
{
|
{
|
||||||
EconomyHandler economy = new EconomyHandler(this, integration);
|
economy = new EconomyHandler(this, integration);
|
||||||
getServer().getPluginManager().registerEvents(economy, this);
|
getServer().getPluginManager().registerEvents(economy, this);
|
||||||
String[] econCmds = {"balance", "pay", "baltop", "shop", "sell", "market", "orders", "trade", "transactions"};
|
String[] econCmds = {"balance", "pay", "baltop", "shop", "sell", "market", "orders", "trade", "transactions"};
|
||||||
for (String c : econCmds) bindCommand(c, economy);
|
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
|
// Register Guilds & Land Claims commands & GUI
|
||||||
{
|
{
|
||||||
GuildHandler guilds = new GuildHandler(this, integration);
|
GuildHandler guilds = new GuildHandler(this, integration, economy);
|
||||||
getServer().getPluginManager().registerEvents(guilds, this);
|
getServer().getPluginManager().registerEvents(guilds, this);
|
||||||
bindCommand("guild", guilds);
|
bindCommand("guild", guilds);
|
||||||
bindCommand("claim", guilds);
|
bindCommand("claim", guilds);
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ public final class EconomyHandler implements CommandExecutor, Listener {
|
|||||||
// Trade sessions: maps player UUID to active trade session
|
// Trade sessions: maps player UUID to active trade session
|
||||||
private final Map<UUID, TradeSession> activeTrades = new ConcurrentHashMap<>();
|
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.
|
// Each open inventory must retain its own snapshot of listing IDs.
|
||||||
private final Map<Inventory, Map<Integer, Long>> activeMarketListings = new IdentityHashMap<>();
|
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) {
|
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")) {
|
if (args.length > 0 && args[0].equalsIgnoreCase("hand")) {
|
||||||
ItemStack hand = player.getInventory().getItemInMainHand();
|
ItemStack hand = player.getInventory().getItemInMainHand();
|
||||||
if (hand.getType().isAir()) {
|
if (hand.getType().isAir()) {
|
||||||
@@ -233,20 +244,41 @@ public final class EconomyHandler implements CommandExecutor, Listener {
|
|||||||
JsonObject payload = new JsonObject();
|
JsonObject payload = new JsonObject();
|
||||||
payload.addProperty("uuid", player.getUniqueId().toString());
|
payload.addProperty("uuid", player.getUniqueId().toString());
|
||||||
payload.addProperty("username", player.getName());
|
payload.addProperty("username", player.getName());
|
||||||
payload.addProperty("delta", total);
|
payload.addProperty(guild ? "amount" : "delta", total);
|
||||||
payload.addProperty("description", "Sell hand: " + sold.getType().name());
|
payload.addProperty("description", "Sell hand: " + sold.getType().name());
|
||||||
player.getInventory().setItemInMainHand(null);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open sell chest GUI
|
// Open sell chest GUI
|
||||||
Inventory sellInv = Bukkit.createInventory(null, 54, GUI_SELL_TITLE);
|
Inventory sellInv = Bukkit.createInventory(null, 54, GUI_SELL_TITLE);
|
||||||
|
if (guild) guildSellChests.add(sellInv);
|
||||||
player.openInventory(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) {
|
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")) {
|
if (args.length >= 2 && args[0].equalsIgnoreCase("sell")) {
|
||||||
ItemStack hand = player.getInventory().getItemInMainHand();
|
ItemStack hand = player.getInventory().getItemInMainHand();
|
||||||
if (hand.getType().isAir()) {
|
if (hand.getType().isAir()) {
|
||||||
@@ -275,6 +307,7 @@ public final class EconomyHandler implements CommandExecutor, Listener {
|
|||||||
payload.addProperty("amount", amount);
|
payload.addProperty("amount", amount);
|
||||||
payload.addProperty("price", price);
|
payload.addProperty("price", price);
|
||||||
payload.addProperty("item_data", EconomyOperations.encode(listed));
|
payload.addProperty("item_data", EconomyOperations.encode(listed));
|
||||||
|
if (asGuild) payload.addProperty("as_guild", true);
|
||||||
|
|
||||||
// Escrow the exact stack before persisting a retryable operation.
|
// Escrow the exact stack before persisting a retryable operation.
|
||||||
player.getInventory().setItemInMainHand(null);
|
player.getInventory().setItemInMainHand(null);
|
||||||
@@ -314,6 +347,9 @@ public final class EconomyHandler implements CommandExecutor, Listener {
|
|||||||
JsonObject listing = el.getAsJsonObject();
|
JsonObject listing = el.getAsJsonObject();
|
||||||
long id = listing.has("id") ? listing.get("id").getAsLong() : -1;
|
long id = listing.has("id") ? listing.get("id").getAsLong() : -1;
|
||||||
String sellerName = listing.has("seller_name") ? listing.get("seller_name").getAsString() : "Unknown";
|
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 listItemId = listing.has("item_id") ? listing.get("item_id").getAsString() : "STONE";
|
||||||
String listItemName = listing.has("item_name") ? listing.get("item_name").getAsString() : listItemId;
|
String listItemName = listing.has("item_name") ? listing.get("item_name").getAsString() : listItemId;
|
||||||
int listAmt = listing.has("amount") ? listing.get("amount").getAsInt() : 1;
|
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);
|
inventoryListings.put(slot, id);
|
||||||
inv.setItem(slot, GuiHelper.createItem(mat, listAmt,
|
inv.setItem(slot, GuiHelper.createItem(mat, listAmt,
|
||||||
"&f" + listItemName + " &7(x" + 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),
|
"&7Price: &a$" + String.format(Locale.US, "%,.2f", listPrice),
|
||||||
|
"&8Listing #" + id,
|
||||||
"",
|
"",
|
||||||
"&eClick to Buy!"));
|
"&eClick to Buy!"));
|
||||||
slot++;
|
slot++;
|
||||||
@@ -447,6 +484,7 @@ public final class EconomyHandler implements CommandExecutor, Listener {
|
|||||||
if (title.equals(GUI_SELL_TITLE)) {
|
if (title.equals(GUI_SELL_TITLE)) {
|
||||||
// Move sellable items to durable escrow before crediting the balance.
|
// Move sellable items to durable escrow before crediting the balance.
|
||||||
Inventory inv = event.getInventory();
|
Inventory inv = event.getInventory();
|
||||||
|
boolean toGuild = guildSellChests.remove(inv);
|
||||||
List<ItemStack> soldItems = new ArrayList<>();
|
List<ItemStack> soldItems = new ArrayList<>();
|
||||||
List<ItemStack> unsellable = new ArrayList<>();
|
List<ItemStack> unsellable = new ArrayList<>();
|
||||||
double totalEarned = 0;
|
double totalEarned = 0;
|
||||||
@@ -472,9 +510,9 @@ public final class EconomyHandler implements CommandExecutor, Listener {
|
|||||||
JsonObject payload = new JsonObject();
|
JsonObject payload = new JsonObject();
|
||||||
payload.addProperty("uuid", player.getUniqueId().toString());
|
payload.addProperty("uuid", player.getUniqueId().toString());
|
||||||
payload.addProperty("username", player.getName());
|
payload.addProperty("username", player.getName());
|
||||||
payload.addProperty("delta", totalEarned);
|
payload.addProperty(toGuild ? "amount" : "delta", totalEarned);
|
||||||
payload.addProperty("description", "Sell chest: " + countSold + " items");
|
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);
|
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());
|
player.sendMessage(ChatColor.RED + "Transaction rejected; held items returned: " + job.get("error").getAsString());
|
||||||
} else {
|
} else {
|
||||||
JsonObject result = job.getAsJsonObject("result");
|
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");
|
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()) : "";
|
String balance = balanceValue != null ? " Balance: $" + String.format(Locale.US, "%,.2f", balanceValue.getAsDouble()) : "";
|
||||||
player.sendMessage(ChatColor.GREEN + "Transaction completed." + balance);
|
player.sendMessage(ChatColor.GREEN + "Transaction completed." + balance);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (Exception e) { plugin.getLogger().severe("Economy delivery requires attention for " + id + ": " + e.getMessage()); }
|
} 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";
|
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.plugin = plugin;
|
||||||
this.integration = integration;
|
this.integration = integration;
|
||||||
|
this.economy = economy;
|
||||||
|
this.bank = new GuildBank(plugin, integration, economy.operations());
|
||||||
this.guildHomesFile = new File(plugin.getDataFolder(), "guild_homes.yml");
|
this.guildHomesFile = new File(plugin.getDataFolder(), "guild_homes.yml");
|
||||||
this.guildHomesConfig = YamlConfiguration.loadConfiguration(guildHomesFile);
|
this.guildHomesConfig = YamlConfiguration.loadConfiguration(guildHomesFile);
|
||||||
loadGuildHomes();
|
loadGuildHomes();
|
||||||
@@ -90,7 +95,7 @@ public final class GuildHandler implements CommandExecutor, Listener {
|
|||||||
|
|
||||||
String sub = args[0].toLowerCase();
|
String sub = args[0].toLowerCase();
|
||||||
String permission = sub.equals("c") ? "chat" : sub;
|
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.hasPermission("scopenet.command.guild." + permission)) {
|
||||||
player.sendMessage(ChatColor.RED + "You do not have permission to use /guild " + permission + ".");
|
player.sendMessage(ChatColor.RED + "You do not have permission to use /guild " + permission + ".");
|
||||||
return true;
|
return true;
|
||||||
@@ -105,6 +110,10 @@ public final class GuildHandler implements CommandExecutor, Listener {
|
|||||||
case "sethome" -> handleSetHome(player);
|
case "sethome" -> handleSetHome(player);
|
||||||
case "home" -> handleHome(player);
|
case "home" -> handleHome(player);
|
||||||
case "members" -> handleMembers(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);
|
default -> sendGuildHelp(player);
|
||||||
}
|
}
|
||||||
return true;
|
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 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 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 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) {
|
private void handleClaim(Player player) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { toast } from '../lib/store.svelte';
|
import { toast } from '../lib/store.svelte';
|
||||||
import type { Guild, GuildMember } from '../lib/types';
|
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[] };
|
type WalletData = { balance: number; my_balance?: number; role?: string; currency_symbol?: string; transactions: Tx[] };
|
||||||
|
|
||||||
let { guild, members, servers, instanceId }: {
|
let { guild, members, servers, instanceId }: {
|
||||||
@@ -36,9 +36,14 @@
|
|||||||
const overLimit = $derived(validAmount && limit != null && value > limit + 1e-9);
|
const overLimit = $derived(validAmount && limit != null && value > limit + 1e-9);
|
||||||
const afterGuild = $derived(wallet && validAmount ? wallet.balance + (mode === 'deposit' ? value : -value) : null);
|
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);
|
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({
|
const totals = $derived({
|
||||||
in: wallet?.transactions.filter((t) => t.kind === 'deposit').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) => t.kind === 'withdraw').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];
|
const quick = [10, 50, 100, 500, 1000];
|
||||||
|
|
||||||
@@ -136,8 +141,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="amount">{money(shown)}</div>
|
<div class="amount">{money(shown)}</div>
|
||||||
<div class="t-stats">
|
<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"><TrendingUp size={12} /> Money in</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"><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><span class="k"><Wallet size={12} /> Your wallet</span><strong>{wallet.my_balance != null ? money(wallet.my_balance) : '—'}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="t-note tiny">Totals cover the last {wallet.transactions.length} transfers.</p>
|
<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)}
|
{#each wallet.transactions as tx (tx.id)}
|
||||||
{@const m = who(tx.actor_uuid)}
|
{@const m = who(tx.actor_uuid)}
|
||||||
<li>
|
<li>
|
||||||
<span class="ic" class:out={tx.kind === 'withdraw'}>
|
<span class="ic" class:out={isOut(tx.kind)}>
|
||||||
{#if tx.kind === 'withdraw'}<ArrowUpFromLine size={14} />{:else}<ArrowDownToLine size={14} />{/if}
|
{#if isOut(tx.kind)}<ArrowUpFromLine size={14} />{:else}<ArrowDownToLine size={14} />{/if}
|
||||||
</span>
|
</span>
|
||||||
<Avatar uuid={tx.actor_uuid} name={m?.name ?? '?'} size={1.7} />
|
<Avatar uuid={tx.actor_uuid} name={m?.name ?? '?'} size={1.7} />
|
||||||
<div class="who">
|
<div class="who">
|
||||||
<strong>{m?.name ?? 'Former member'}</strong>
|
<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>
|
</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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -570,6 +570,11 @@ const MIGRATIONS: &[&str] = &[
|
|||||||
);
|
);
|
||||||
CREATE INDEX quest_assignments_user ON quest_assignments(user_uuid, period_key, position);
|
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
|
// Bumped by any change to claims, guilds or membership so game servers can
|
||||||
// poll a single number instead of asking about every block.
|
// poll a single number instead of asking about every block.
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ pub async fn purge_user(state: &AppState, user: &UserRow) -> AppResult<PurgeRepo
|
|||||||
|
|
||||||
// ---- economy ----
|
// ---- economy ----
|
||||||
report.add("economy", run(c, "DELETE FROM server_economy WHERE uuid = ?", &[uuid]).await?);
|
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?);
|
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 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?;
|
run(c, "UPDATE economy_transactions SET to_uuid = ?, to_name = ? WHERE to_uuid = ?", &[DELETED_UUID, DELETED_NAME, uuid]).await?;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use scopenet_shared::{BaltopEntry, EconomyTransaction, MarketListing, ServerEcon
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
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")); }
|
if operation_id.is_empty() || operation_id.len() > 100 { return Err(AppError::bad_request("Invalid operation ID")); }
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
// The first statement obtains the write lock before reading any balances.
|
// 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))
|
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 = ?")
|
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?;
|
.bind(response.to_string()).bind(server_id).bind(operation_id).execute(&mut *tx).await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok(Json(response))
|
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")
|
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?;
|
.bind(server_id).bind(uuid).bind(name).bind(chrono::Utc::now().to_rfc3339()).execute(&mut **tx).await?;
|
||||||
Ok(())
|
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>>> {
|
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?;
|
.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>,
|
Path(server_id): Path<i64>,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> AppResult<Json<Vec<MarketListing>>> {
|
) -> AppResult<Json<Vec<MarketListing>>> {
|
||||||
let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String)> = sqlx::query_as(
|
let rows: Vec<(i64, i64, String, String, String, String, i32, f64, String, Option<String>)> = sqlx::query_as(
|
||||||
"SELECT id, server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at
|
"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
|
FROM server_market m LEFT JOIN guilds g ON g.id = m.seller_guild_id
|
||||||
WHERE server_id = ?
|
WHERE m.server_id = ?
|
||||||
ORDER BY id DESC
|
ORDER BY m.id DESC
|
||||||
LIMIT 100",
|
LIMIT 100",
|
||||||
)
|
)
|
||||||
.bind(server_id)
|
.bind(server_id)
|
||||||
@@ -184,7 +186,8 @@ pub async fn get_server_market(
|
|||||||
|
|
||||||
let list = rows
|
let list = rows
|
||||||
.into_iter()
|
.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,
|
id,
|
||||||
server_id: sid,
|
server_id: sid,
|
||||||
seller_uuid: suuid,
|
seller_uuid: suuid,
|
||||||
@@ -411,6 +414,9 @@ pub struct MarketListPayload {
|
|||||||
pub item_name: String,
|
pub item_name: String,
|
||||||
pub amount: i32,
|
pub amount: i32,
|
||||||
pub price: f64,
|
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(
|
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?;
|
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
|
||||||
if let Some(previous) = previous { return Ok(Json(previous)); }
|
if let Some(previous) = previous { return Ok(Json(previous)); }
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
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(
|
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)
|
"INSERT INTO server_market (server_id, seller_uuid, seller_name, item_id, item_name, amount, price, created_at, item_data, seller_guild_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(server.id)
|
.bind(server.id)
|
||||||
@@ -436,6 +447,7 @@ pub async fn server_market_list(
|
|||||||
.bind(payload.price)
|
.bind(payload.price)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.bind(&payload.item_data)
|
.bind(&payload.item_data)
|
||||||
|
.bind(&guild_id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -448,6 +460,9 @@ pub struct MarketBuyPayload {
|
|||||||
pub listing_id: i64,
|
pub listing_id: i64,
|
||||||
pub buyer_uuid: String,
|
pub buyer_uuid: String,
|
||||||
pub buyer_name: 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(
|
pub async fn server_market_buy(
|
||||||
@@ -457,10 +472,10 @@ pub async fn server_market_buy(
|
|||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
|
let (mut tx, previous) = begin_operation(&state, server.id, &payload.operation_id).await?;
|
||||||
if let Some(previous) = previous { return Ok(Json(previous)); }
|
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
|
"DELETE FROM server_market
|
||||||
WHERE id = ? AND server_id = ?
|
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)
|
.bind(payload.listing_id)
|
||||||
@@ -468,52 +483,52 @@ pub async fn server_market_buy(
|
|||||||
.fetch_optional(&mut *tx)
|
.fetch_optional(&mut *tx)
|
||||||
.await?;
|
.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"));
|
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?;
|
ensure_balance(&mut tx, server.id, &payload.buyer_uuid, &payload.buyer_name).await?;
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
// Deduct buyer
|
// Buyer pays: their own balance, or their guild's bank.
|
||||||
let buyer_bal: f64 = sqlx::query_scalar(
|
let new_balance = if let Some(m) = &buyer_guild {
|
||||||
"SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?",
|
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?;
|
||||||
.bind(server.id)
|
left
|
||||||
.bind(&payload.buyer_uuid)
|
} else {
|
||||||
.fetch_optional(&mut *tx)
|
let buyer_bal: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?")
|
||||||
.await?
|
.bind(server.id).bind(&payload.buyer_uuid).fetch_optional(&mut *tx).await?.unwrap_or(0.0);
|
||||||
.unwrap_or(0.0);
|
|
||||||
|
|
||||||
if buyer_bal < price {
|
if buyer_bal < price {
|
||||||
return Err(AppError::bad_request("Insufficient funds"));
|
return Err(AppError::bad_request("Insufficient funds"));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("UPDATE server_economy SET balance = balance - ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
|
sqlx::query("UPDATE server_economy SET balance = balance - ?, updated_at = ? WHERE server_id = ? AND uuid = ?")
|
||||||
.bind(price)
|
.bind(price).bind(&now).bind(server.id).bind(&payload.buyer_uuid).execute(&mut *tx).await?;
|
||||||
.bind(&now)
|
buyer_bal - price
|
||||||
.bind(server.id)
|
};
|
||||||
.bind(&payload.buyer_uuid)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
let new_balance = buyer_bal - price;
|
|
||||||
|
|
||||||
// Pay seller
|
// Seller is paid: the guild bank for guild listings, otherwise the player.
|
||||||
sqlx::query(
|
let (to_uuid, to_name) = if let Some((guild_id, tag)) = &seller_guild {
|
||||||
"INSERT INTO server_economy (server_id, uuid, username, balance, updated_at)
|
crate::routes::guild_bank::adjust_wallet(&mut tx, server.id, guild_id, price).await?;
|
||||||
VALUES (?, ?, ?, ?, ?)
|
crate::routes::guild_bank::log(&mut tx, server.id, guild_id, &seller_uuid, "sale", price, &format!("Market: {amount}x {item_name}")).await?;
|
||||||
ON CONFLICT (server_id, uuid) DO UPDATE SET
|
(format!("guild:{guild_id}"), format!("[{tag}] guild bank"))
|
||||||
balance = balance + excluded.balance,
|
} else {
|
||||||
username = excluded.username,
|
// A seller without an account first gets the usual starting balance, then the sale on top.
|
||||||
updated_at = excluded.updated_at",
|
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(server.id)
|
.bind(price).bind(&seller_name).bind(&now).bind(server.id).bind(&seller_uuid)
|
||||||
.bind(&seller_uuid)
|
.execute(&mut *tx).await?;
|
||||||
.bind(&seller_name)
|
(seller_uuid.clone(), seller_name.clone())
|
||||||
.bind(price)
|
};
|
||||||
.bind(&now)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Delete listing
|
// Delete listing
|
||||||
sqlx::query("DELETE FROM server_market WHERE id = ?")
|
sqlx::query("DELETE FROM server_market WHERE id = ?")
|
||||||
@@ -528,10 +543,10 @@ pub async fn server_market_buy(
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(server.id)
|
.bind(server.id)
|
||||||
.bind(&payload.buyer_uuid)
|
.bind(match &buyer_guild { Some(m) => format!("guild:{}", m.guild_id), None => payload.buyer_uuid.clone() })
|
||||||
.bind(&payload.buyer_name)
|
.bind(match &buyer_guild { Some(m) => format!("[{}] guild bank", m.tag), None => payload.buyer_name.clone() })
|
||||||
.bind(&seller_uuid)
|
.bind(&to_uuid)
|
||||||
.bind(&seller_name)
|
.bind(&to_name)
|
||||||
.bind(price)
|
.bind(price)
|
||||||
.bind(&desc)
|
.bind(&desc)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//! The guild bank as seen from the game server: check the balance, move
|
||||||
|
//! money between a player and the guild, credit shop sales to the guild, and
|
||||||
|
//! pay other guilds. Every mutating call is idempotent on `operation_id`.
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::routes::economy::{begin_operation, ensure_balance, finish_operation};
|
||||||
|
use crate::routes::servers::{GameServer, ServerRow};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use sqlx::SqliteConnection;
|
||||||
|
|
||||||
|
/// A player's place in the guild that plays on this server's instance.
|
||||||
|
pub struct Membership {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub tag: String,
|
||||||
|
pub role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Membership {
|
||||||
|
pub fn can_spend(&self) -> bool {
|
||||||
|
matches!(self.role.as_str(), "leader" | "officer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn membership(conn: &mut SqliteConnection, server: &ServerRow, uuid: &str) -> AppResult<Option<Membership>> {
|
||||||
|
let row: Option<(String, String, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT g.id, g.name, g.tag, gm.role FROM guild_members gm JOIN guilds g ON g.id = gm.guild_id
|
||||||
|
WHERE gm.uuid = ? AND g.instance_id = ?",
|
||||||
|
)
|
||||||
|
.bind(uuid)
|
||||||
|
.bind(&server.instance_id)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|(guild_id, name, tag, role)| Membership { guild_id, name, tag, role }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn valid_amount(amount: f64) -> AppResult<()> {
|
||||||
|
if !amount.is_finite() || !(0.01..=1e9).contains(&amount) || (amount * 100.0 - (amount * 100.0).round()).abs() > 1e-6 {
|
||||||
|
return Err(AppError::bad_request("Amount must be between 0.01 and 1,000,000,000 with at most two decimals"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_wallet(conn: &mut SqliteConnection, server_id: i64, guild_id: &str) -> AppResult<()> {
|
||||||
|
sqlx::query("INSERT INTO guild_wallets(server_id, guild_id, balance, updated_at) VALUES (?, ?, 0, ?) ON CONFLICT(server_id, guild_id) DO NOTHING")
|
||||||
|
.bind(server_id)
|
||||||
|
.bind(guild_id)
|
||||||
|
.bind(chrono::Utc::now().to_rfc3339())
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move `delta` into (positive) or out of (negative) a guild wallet. Fails if it would go below zero.
|
||||||
|
pub async fn adjust_wallet(conn: &mut SqliteConnection, server_id: i64, guild_id: &str, delta: f64) -> AppResult<f64> {
|
||||||
|
ensure_wallet(conn, server_id, guild_id).await?;
|
||||||
|
let balance: Option<f64> = sqlx::query_scalar(
|
||||||
|
"UPDATE guild_wallets SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND guild_id = ? AND balance + ? >= 0 RETURNING balance",
|
||||||
|
)
|
||||||
|
.bind(delta)
|
||||||
|
.bind(chrono::Utc::now().to_rfc3339())
|
||||||
|
.bind(server_id)
|
||||||
|
.bind(guild_id)
|
||||||
|
.bind(delta)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
balance.ok_or_else(|| AppError::bad_request("The guild bank doesn't have enough funds"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn log(conn: &mut SqliteConnection, server_id: i64, guild_id: &str, actor: &str, kind: &str, amount: f64, note: &str) -> AppResult<()> {
|
||||||
|
sqlx::query("INSERT INTO guild_wallet_transactions(server_id, guild_id, actor_uuid, kind, amount, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||||
|
.bind(server_id)
|
||||||
|
.bind(guild_id)
|
||||||
|
.bind(actor)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(note.chars().filter(|c| !c.is_control()).take(120).collect::<String>())
|
||||||
|
.bind(chrono::Utc::now().to_rfc3339())
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn player_balance(conn: &mut SqliteConnection, server_id: i64, uuid: &str) -> AppResult<f64> {
|
||||||
|
Ok(sqlx::query_scalar("SELECT balance FROM server_economy WHERE server_id = ? AND uuid = ?").bind(server_id).bind(uuid).fetch_optional(&mut *conn).await?.unwrap_or(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Who {
|
||||||
|
pub uuid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bank summary for `/guild bank`: who the player's guild is, their role, the balance and recent activity.
|
||||||
|
pub async fn server_bank_info(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Who>) -> AppResult<Json<Value>> {
|
||||||
|
let mut conn = state.db.acquire().await?;
|
||||||
|
let Some(m) = membership(&mut conn, &server, &p.uuid).await? else {
|
||||||
|
return Ok(Json(json!({ "guild": null })));
|
||||||
|
};
|
||||||
|
let balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE server_id = ? AND guild_id = ?")
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&m.guild_id)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let recent: Vec<(String, String, f64, String, String)> = sqlx::query_as(
|
||||||
|
"SELECT t.kind, COALESCE((SELECT gm.name FROM guild_members gm WHERE gm.guild_id = t.guild_id AND gm.uuid = t.actor_uuid), 'Former member'), t.amount, t.note, t.created_at
|
||||||
|
FROM guild_wallet_transactions t WHERE t.server_id = ? AND t.guild_id = ? ORDER BY t.id DESC LIMIT 8",
|
||||||
|
)
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&m.guild_id)
|
||||||
|
.fetch_all(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"guild": { "id": m.guild_id, "name": m.name, "tag": m.tag },
|
||||||
|
"role": m.role,
|
||||||
|
"can_spend": m.can_spend(),
|
||||||
|
"balance": balance,
|
||||||
|
"my_balance": player_balance(&mut conn, server.id, &p.uuid).await?,
|
||||||
|
"recent": recent.into_iter().map(|(kind, who, amount, note, at)| json!({ "kind": kind, "who": who, "amount": amount, "note": note, "at": at })).collect::<Vec<_>>(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Transfer {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub uuid: String,
|
||||||
|
pub username: String,
|
||||||
|
pub amount: f64,
|
||||||
|
/// `deposit` (player → guild) or `withdraw` (guild → player, officers and leaders).
|
||||||
|
pub action: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn server_bank_transfer(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Transfer>) -> AppResult<Json<Value>> {
|
||||||
|
valid_amount(p.amount)?;
|
||||||
|
let withdraw = match p.action.as_str() {
|
||||||
|
"deposit" => false,
|
||||||
|
"withdraw" => true,
|
||||||
|
_ => return Err(AppError::bad_request("action must be deposit or withdraw")),
|
||||||
|
};
|
||||||
|
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
|
||||||
|
if let Some(previous) = previous {
|
||||||
|
return Ok(Json(previous));
|
||||||
|
}
|
||||||
|
let m = membership(&mut tx, &server, &p.uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
|
||||||
|
if withdraw && !m.can_spend() {
|
||||||
|
return Err(AppError::forbidden("Only guild leaders and officers can withdraw"));
|
||||||
|
}
|
||||||
|
ensure_balance(&mut tx, server.id, &p.uuid, &p.username).await?;
|
||||||
|
let (guild_delta, player_delta) = if withdraw { (-p.amount, p.amount) } else { (p.amount, -p.amount) };
|
||||||
|
let moved: Option<f64> = sqlx::query_scalar(
|
||||||
|
"UPDATE server_economy SET balance = balance + ?, updated_at = ? WHERE server_id = ? AND uuid = ? AND balance + ? >= 0 RETURNING balance",
|
||||||
|
)
|
||||||
|
.bind(player_delta)
|
||||||
|
.bind(chrono::Utc::now().to_rfc3339())
|
||||||
|
.bind(server.id)
|
||||||
|
.bind(&p.uuid)
|
||||||
|
.bind(player_delta)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let my_balance = moved.ok_or_else(|| AppError::bad_request("Insufficient funds"))?;
|
||||||
|
let balance = adjust_wallet(&mut tx, server.id, &m.guild_id, guild_delta).await?;
|
||||||
|
log(&mut tx, server.id, &m.guild_id, &p.uuid, &p.action, p.amount, "").await?;
|
||||||
|
let message = if withdraw { format!("Withdrew ${:.2} from [{}] bank.", p.amount, m.tag) } else { format!("Deposited ${:.2} into [{}] bank.", p.amount, m.tag) };
|
||||||
|
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "balance": balance, "my_balance": my_balance, "message": message })).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Credit {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub uuid: String,
|
||||||
|
pub username: String,
|
||||||
|
pub amount: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shop sales made with `/guild sell`: the proceeds go to the guild bank instead of the seller.
|
||||||
|
pub async fn server_bank_credit(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Credit>) -> AppResult<Json<Value>> {
|
||||||
|
valid_amount(p.amount)?;
|
||||||
|
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
|
||||||
|
if let Some(previous) = previous {
|
||||||
|
return Ok(Json(previous));
|
||||||
|
}
|
||||||
|
let m = membership(&mut tx, &server, &p.uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
|
||||||
|
let balance = adjust_wallet(&mut tx, server.id, &m.guild_id, p.amount).await?;
|
||||||
|
let note = if p.description.is_empty() { "Items sold to the server shop" } else { &p.description };
|
||||||
|
log(&mut tx, server.id, &m.guild_id, &p.uuid, "sale", p.amount, note).await?;
|
||||||
|
let message = format!("Sold for ${:.2}, paid into the [{}] bank.", p.amount, m.tag);
|
||||||
|
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "balance": balance, "message": message })).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Pay {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub uuid: String,
|
||||||
|
/// Tag of the receiving guild.
|
||||||
|
pub to_tag: String,
|
||||||
|
pub amount: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub note: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guild-to-guild payment (leaders and officers).
|
||||||
|
pub async fn server_bank_pay(GameServer(server): GameServer, State(state): State<AppState>, Json(p): Json<Pay>) -> AppResult<Json<Value>> {
|
||||||
|
valid_amount(p.amount)?;
|
||||||
|
let (mut tx, previous) = begin_operation(&state, server.id, &p.operation_id).await?;
|
||||||
|
if let Some(previous) = previous {
|
||||||
|
return Ok(Json(previous));
|
||||||
|
}
|
||||||
|
let m = membership(&mut tx, &server, &p.uuid).await?.ok_or_else(|| AppError::forbidden("You are not in a guild"))?;
|
||||||
|
if !m.can_spend() {
|
||||||
|
return Err(AppError::forbidden("Only guild leaders and officers can spend guild money"));
|
||||||
|
}
|
||||||
|
let target: Option<(String, String, String)> =
|
||||||
|
sqlx::query_as("SELECT id, name, tag FROM guilds WHERE instance_id = ? AND tag = ? COLLATE NOCASE AND id <> ?")
|
||||||
|
.bind(&server.instance_id)
|
||||||
|
.bind(p.to_tag.trim())
|
||||||
|
.bind(&m.guild_id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
let (target_id, target_name, target_tag) = target.ok_or_else(|| AppError::not_found("No other guild with that tag"))?;
|
||||||
|
let balance = adjust_wallet(&mut tx, server.id, &m.guild_id, -p.amount).await?;
|
||||||
|
adjust_wallet(&mut tx, server.id, &target_id, p.amount).await?;
|
||||||
|
let note = p.note.chars().take(60).collect::<String>();
|
||||||
|
log(&mut tx, server.id, &m.guild_id, &p.uuid, "transfer_out", p.amount, &format!("to [{target_tag}] {target_name} {note}")).await?;
|
||||||
|
log(&mut tx, server.id, &target_id, &p.uuid, "transfer_in", p.amount, &format!("from [{}] {} {note}", m.tag, m.name)).await?;
|
||||||
|
let message = format!("Paid ${:.2} from [{}] to [{}] {}.", p.amount, m.tag, target_tag, target_name);
|
||||||
|
finish_operation(tx, server.id, &p.operation_id, json!({ "ok": true, "balance": balance, "message": message })).await
|
||||||
|
}
|
||||||
@@ -150,14 +150,14 @@ pub async fn guild_wallet(
|
|||||||
if !member { return Err(AppError::forbidden("Guild membership is required")); }
|
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=?")
|
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);
|
.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?;
|
.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=?")
|
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?;
|
.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.
|
// 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=?")
|
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);
|
.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>> {
|
pub async fn guild_wallet_deposit(auth: AuthUser, Path(guild_id): Path<String>, State(state): State<AppState>, Json(p): Json<GuildWalletTransfer>) -> AppResult<Json<Value>> {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod activity;
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod connections;
|
pub mod connections;
|
||||||
pub mod economy;
|
pub mod economy;
|
||||||
|
pub mod guild_bank;
|
||||||
pub mod guilds;
|
pub mod guilds;
|
||||||
pub mod landing;
|
pub mod landing;
|
||||||
pub mod livemap;
|
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/player", post(guilds::server_get_player_guild))
|
||||||
.route("/guilds/create", post(guilds::server_create_guild))
|
.route("/guilds/create", post(guilds::server_create_guild))
|
||||||
.route("/guilds/leave", post(guilds::server_guild_leave))
|
.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/balance", post(economy::server_get_balance))
|
||||||
.route("/economy/pay", post(economy::server_transfer))
|
.route("/economy/pay", post(economy::server_transfer))
|
||||||
.route("/economy/transfer", post(economy::server_transfer))
|
.route("/economy/transfer", post(economy::server_transfer))
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Guild bank: deposits, officer-only spending, shop sales, guild-to-guild
|
||||||
|
//! payments and guild market trades, all idempotent on their operation id.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
t: TestApp,
|
||||||
|
server: String,
|
||||||
|
sid: i64,
|
||||||
|
alex: String,
|
||||||
|
steve: String,
|
||||||
|
alex_uuid: String,
|
||||||
|
steve_uuid: String,
|
||||||
|
gid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fixture() -> Fixture {
|
||||||
|
let t = setup().await;
|
||||||
|
let admin = t.login("admin", "supersecret").await;
|
||||||
|
for n in ["Alex", "Steve", "Mia"] {
|
||||||
|
t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": n, "password": "password123"}))).await;
|
||||||
|
}
|
||||||
|
let (alex, steve, mia) = (t.login("Alex", "password123").await, t.login("Steve", "password123").await, t.login("Mia", "password123").await);
|
||||||
|
let (alex_uuid, steve_uuid) = (t.uuid("Alex").await, t.uuid("Steve").await);
|
||||||
|
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await;
|
||||||
|
let sid = srv["server"]["id"].as_i64().unwrap();
|
||||||
|
let server = srv["token"].as_str().unwrap().to_string();
|
||||||
|
let (_, g) = t.call("POST", "/api/v1/guilds", Some(&alex), Some(json!({"instance_id": "smp", "name": "Iron", "tag": "IRON"}))).await;
|
||||||
|
let gid = g["id"].as_str().unwrap().to_string();
|
||||||
|
sqlx::query("INSERT INTO guild_members (guild_id, uuid, name, role, joined_at) VALUES (?, ?, 'Steve', 'member', '2026-01-01')").bind(&gid).bind(&steve_uuid).execute(&t.db).await.unwrap();
|
||||||
|
// Mia leads a second guild.
|
||||||
|
t.call("POST", "/api/v1/guilds", Some(&mia), Some(json!({"instance_id": "smp", "name": "Void", "tag": "VOID"}))).await;
|
||||||
|
Fixture { t, server, sid, alex, steve, alex_uuid, steve_uuid, gid }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
async fn post(&self, path: &str, body: Value) -> (StatusCode, Value) {
|
||||||
|
self.t.call("POST", &format!("/api/server/v1/{path}"), Some(&self.server), Some(body)).await
|
||||||
|
}
|
||||||
|
async fn guild_balance(&self) -> f64 {
|
||||||
|
sqlx::query_scalar("SELECT COALESCE((SELECT balance FROM guild_wallets WHERE guild_id = ? AND server_id = ?), 0)").bind(&self.gid).bind(self.sid).fetch_one(&self.t.db).await.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bank_deposits_withdrawals_and_roles() {
|
||||||
|
let f = fixture().await;
|
||||||
|
let (s, info) = f.post("guilds/bank", json!({"uuid": f.alex_uuid})).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!((info["guild"]["tag"].as_str(), info["role"].as_str(), info["balance"].as_f64()), (Some("IRON"), Some("leader"), Some(0.0)));
|
||||||
|
assert_eq!(f.post("guilds/bank", json!({"uuid": "00000000-0000-0000-0000-000000000001"})).await.1["guild"], Value::Null);
|
||||||
|
|
||||||
|
// Members can deposit from their own balance (new accounts start with 1000).
|
||||||
|
let dep = json!({"operation_id": "op-1", "uuid": f.steve_uuid, "username": "Steve", "amount": 250.5, "action": "deposit"});
|
||||||
|
let (s, r) = f.post("guilds/bank/transfer", dep.clone()).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{r}");
|
||||||
|
assert_eq!((r["balance"].as_f64(), r["my_balance"].as_f64()), (Some(250.5), Some(749.5)));
|
||||||
|
// A retried request returns the same answer and moves no money twice.
|
||||||
|
let (_, again) = f.post("guilds/bank/transfer", dep).await;
|
||||||
|
assert_eq!(again, r);
|
||||||
|
assert_eq!(f.guild_balance().await, 250.5);
|
||||||
|
|
||||||
|
// Only officers and leaders withdraw.
|
||||||
|
let wd = |op: &str, who: &str, name: &str, amount: f64| json!({"operation_id": op, "uuid": who, "username": name, "amount": amount, "action": "withdraw"});
|
||||||
|
assert_eq!(f.post("guilds/bank/transfer", wd("op-2", &f.steve_uuid, "Steve", 10.0)).await.0, StatusCode::FORBIDDEN);
|
||||||
|
let (s, r) = f.post("guilds/bank/transfer", wd("op-3", &f.alex_uuid, "Alex", 100.0)).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{r}");
|
||||||
|
assert_eq!(r["balance"], 150.5);
|
||||||
|
// Not more than the bank holds, not more than a player holds, only valid amounts.
|
||||||
|
assert_eq!(f.post("guilds/bank/transfer", wd("op-4", &f.alex_uuid, "Alex", 9999.0)).await.0, StatusCode::BAD_REQUEST);
|
||||||
|
let big = json!({"operation_id": "op-5", "uuid": f.steve_uuid, "username": "Steve", "amount": 5000.0, "action": "deposit"});
|
||||||
|
assert_eq!(f.post("guilds/bank/transfer", big).await.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(f.post("guilds/bank/transfer", wd("op-6", &f.alex_uuid, "Alex", 0.001)).await.0, StatusCode::BAD_REQUEST);
|
||||||
|
assert_eq!(f.guild_balance().await, 150.5);
|
||||||
|
|
||||||
|
// The launcher sees the same history, with who did it.
|
||||||
|
let (s, w) = f.t.call("GET", &format!("/api/v1/guilds/{}/wallet?server_id={}", f.gid, f.sid), Some(&f.steve), None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{w}");
|
||||||
|
assert_eq!(w["balance"], 150.5);
|
||||||
|
assert_eq!(w["transactions"].as_array().unwrap().len(), 2);
|
||||||
|
assert_eq!(w["role"], "member");
|
||||||
|
let (_, w) = f.t.call("GET", &format!("/api/v1/guilds/{}/wallet?server_id={}", f.gid, f.sid), Some(&f.alex), None).await;
|
||||||
|
assert_eq!(w["role"], "leader");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shop_sales_and_guild_payments() {
|
||||||
|
let f = fixture().await;
|
||||||
|
let credit = json!({"operation_id": "sale-1", "uuid": f.steve_uuid, "username": "Steve", "amount": 64.0, "description": "64x Cobblestone"});
|
||||||
|
let (s, r) = f.post("guilds/bank/credit", credit.clone()).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{r}");
|
||||||
|
assert_eq!(f.post("guilds/bank/credit", credit).await.1, r);
|
||||||
|
assert_eq!(f.guild_balance().await, 64.0);
|
||||||
|
let (_, nobody) = f.post("guilds/bank/credit", json!({"operation_id": "sale-2", "uuid": "00000000-0000-0000-0000-000000000009", "username": "X", "amount": 5.0})).await;
|
||||||
|
assert!(nobody["error"].as_str().unwrap().contains("not in a guild"));
|
||||||
|
|
||||||
|
let pay = |op: &str, who: &str, tag: &str, amount: f64| json!({"operation_id": op, "uuid": who, "to_tag": tag, "amount": amount});
|
||||||
|
assert_eq!(f.post("guilds/bank/pay", pay("p1", &f.steve_uuid, "VOID", 10.0)).await.0, StatusCode::FORBIDDEN, "members can't spend");
|
||||||
|
assert_eq!(f.post("guilds/bank/pay", pay("p2", &f.alex_uuid, "IRON", 10.0)).await.0, StatusCode::NOT_FOUND, "not to yourself");
|
||||||
|
assert_eq!(f.post("guilds/bank/pay", pay("p3", &f.alex_uuid, "NOPE", 10.0)).await.0, StatusCode::NOT_FOUND);
|
||||||
|
assert_eq!(f.post("guilds/bank/pay", pay("p4", &f.alex_uuid, "void", 500.0)).await.0, StatusCode::BAD_REQUEST, "insufficient");
|
||||||
|
let (s, r) = f.post("guilds/bank/pay", pay("p5", &f.alex_uuid, "void", 24.0)).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{r}");
|
||||||
|
assert_eq!(r["balance"], 40.0);
|
||||||
|
let void_balance: f64 = sqlx::query_scalar("SELECT balance FROM guild_wallets WHERE guild_id <> ?").bind(&f.gid).fetch_one(&f.t.db).await.unwrap();
|
||||||
|
assert_eq!(void_balance, 24.0);
|
||||||
|
let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM guild_wallet_transactions ORDER BY id").fetch_all(&f.t.db).await.unwrap();
|
||||||
|
assert_eq!(kinds, ["sale", "transfer_out", "transfer_in"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn guild_market_listings_and_purchases() {
|
||||||
|
let f = fixture().await;
|
||||||
|
let list = |op: &str, who: &str, name: &str, guild: bool| json!({"operation_id": op, "seller_uuid": who, "seller_name": name, "item_id": "DIAMOND", "item_name": "Diamond", "amount": 4, "price": 120.0, "as_guild": guild});
|
||||||
|
// Only officers list for the guild.
|
||||||
|
assert_eq!(f.post("economy/market/list", list("l1", &f.steve_uuid, "Steve", true)).await.0, StatusCode::FORBIDDEN);
|
||||||
|
let (s, l) = f.post("economy/market/list", list("l2", &f.alex_uuid, "Alex", true)).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{l}");
|
||||||
|
let listing = l["id"].as_i64().unwrap();
|
||||||
|
let (_, read) = f.post("economy/market", json!({})).await;
|
||||||
|
assert_eq!(read[0]["seller_guild"], "IRON");
|
||||||
|
let (_, public) = f.t.call("GET", &format!("/api/v1/servers/{}/economy/market", f.sid), None, None).await;
|
||||||
|
assert_eq!(public[0]["seller_guild_tag"], "IRON");
|
||||||
|
|
||||||
|
// Mia's guild buys it with guild money: she's an officer but the bank is empty first.
|
||||||
|
let mia_uuid = f.t.uuid("Mia").await;
|
||||||
|
let buy = |op: &str| json!({"operation_id": op, "listing_id": listing, "buyer_uuid": mia_uuid, "buyer_name": "Mia", "as_guild": true});
|
||||||
|
sqlx::query("INSERT INTO guild_wallets (server_id, guild_id, balance, updated_at) SELECT ?, id, 50, 'now' FROM guilds WHERE tag = 'VOID'").bind(f.sid).execute(&f.t.db).await.unwrap();
|
||||||
|
let (s, e) = f.post("economy/market/buy", buy("b1")).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST, "{e}");
|
||||||
|
let still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM server_market").fetch_one(&f.t.db).await.unwrap();
|
||||||
|
assert_eq!(still, 1, "a failed purchase leaves the listing in place");
|
||||||
|
sqlx::query("UPDATE guild_wallets SET balance = 500 WHERE guild_id <> ?").bind(&f.gid).execute(&f.t.db).await.unwrap();
|
||||||
|
let (s, r) = f.post("economy/market/buy", buy("b2")).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{r}");
|
||||||
|
assert_eq!(r["new_balance"], 380.0);
|
||||||
|
assert_eq!(f.guild_balance().await, 120.0, "the sale is paid into the listing guild's bank");
|
||||||
|
let alex_balance: Option<f64> = sqlx::query_scalar("SELECT balance FROM server_economy WHERE uuid = ?").bind(&f.alex_uuid).fetch_optional(&f.t.db).await.unwrap();
|
||||||
|
assert!(alex_balance.is_none(), "the lister is not paid personally");
|
||||||
|
let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM guild_wallet_transactions ORDER BY id").fetch_all(&f.t.db).await.unwrap();
|
||||||
|
assert_eq!(kinds, ["purchase", "sale"]);
|
||||||
|
// Retried purchase is the same answer and charges nothing more.
|
||||||
|
let (_, again) = f.post("economy/market/buy", buy("b2")).await;
|
||||||
|
assert_eq!(again["new_balance"], 380.0);
|
||||||
|
|
||||||
|
// Plain player listings still pay the player.
|
||||||
|
let (_, l2) = f.post("economy/market/list", list("l3", &f.steve_uuid, "Steve", false)).await;
|
||||||
|
let pay = json!({"operation_id": "b3", "listing_id": l2["id"], "buyer_uuid": f.alex_uuid, "buyer_name": "Alex"});
|
||||||
|
let (s, r) = f.post("economy/market/buy", pay).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{r}");
|
||||||
|
assert_eq!(r["new_balance"], 880.0);
|
||||||
|
let steve_balance: f64 = sqlx::query_scalar("SELECT balance FROM server_economy WHERE uuid = ?").bind(&f.steve_uuid).fetch_one(&f.t.db).await.unwrap();
|
||||||
|
assert_eq!(steve_balance, 1120.0);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user