Shared command core: economy, guilds, bank, escrow jobs, tests

This commit is contained in:
Claude committed 2026-09-30 19:28:38 +00:00
1 parent 97bdcbb7af
commit f8862c5431
20 files changed
+1347 -2

No files matched your search

@@ -0,0 +1,31 @@
package net.scopenet.core;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.BiConsumer;
/** A {@link CoreCommand} built from lambdas. */
final class Cmd implements CoreCommand {
private final String name;
private final List<String> aliases;
private final String permission;
private final BiConsumer<CorePlayer, String[]> run;
private final BiFunction<CorePlayer, String[], List<String>> complete;
Cmd(String name, List<String> aliases, String permission, BiConsumer<CorePlayer, String[]> run,
BiFunction<CorePlayer, String[], List<String>> complete) {
this.name = name; this.aliases = aliases; this.permission = permission; this.run = run; this.complete = complete;
}
static Cmd of(String name, List<String> aliases, BiConsumer<CorePlayer, String[]> run) {
return new Cmd(name, aliases, "scopenet.command." + name, run, (p, a) -> List.of());
}
Cmd completing(BiFunction<CorePlayer, String[], List<String>> c) { return new Cmd(name, aliases, permission, run, c); }
@Override public String name() { return name; }
@Override public List<String> aliases() { return aliases; }
@Override public String permission() { return permission; }
@Override public void run(CorePlayer player, String[] args) { run.accept(player, args); }
@Override public List<String> complete(CorePlayer player, String[] args) { return complete.apply(player, args); }
}
@@ -0,0 +1,45 @@
package net.scopenet.core;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Builds every shared command, wired to its services, and gates them on feature switches and permission nodes. */
public final class CommandSet {
public final EssentialsService essentials;
public final Jobs jobs;
private final List<CoreCommand> commands = new ArrayList<>();
public CommandSet(Env env, EssentialsConfig essentialsConfig) {
Path dir = env.dataDir;
essentials = new EssentialsService(env.platform, dir.resolve("essentials.json"), essentialsConfig, env.clock, env.rng);
jobs = new Jobs(env, dir.resolve("economy-pending.json"));
EconomyCommands economy = new EconomyCommands(env, jobs);
GuildCommands guilds = new GuildCommands(env, jobs, economy, essentials, dir.resolve("guild-homes.json"));
add(env, EssentialsCommands.build(env, essentials), "Essentials", () -> env.features.essentials());
add(env, economy.build(), "Economy", () -> env.features.economy());
add(env, guilds.build(), "Guilds", () -> env.features.guilds());
}
private void add(Env env, List<CoreCommand> list, String system, java.util.function.BooleanSupplier enabled) {
for (CoreCommand c : list) commands.add(new Gated(c, system, enabled));
}
public List<CoreCommand> all() { return List.copyOf(commands); }
/** Call about once a second from the server thread. */
public void tick() { jobs.tick(); }
/** Checks the feature switch first, then the permission node, so every platform behaves identically. */
private record Gated(CoreCommand inner, String system, java.util.function.BooleanSupplier enabled) implements CoreCommand {
@Override public String name() { return inner.name(); }
@Override public List<String> aliases() { return inner.aliases(); }
@Override public String permission() { return inner.permission(); }
@Override public void run(CorePlayer p, String[] args) {
if (!enabled.getAsBoolean()) { p.send(Format.RED + system + " are disabled."); return; }
if (!p.hasPermission(inner.permission())) { p.send(Format.RED + "You do not have permission to use /" + inner.name() + "."); return; }
inner.run(p, args);
}
@Override public List<String> complete(CorePlayer p, String[] args) { return inner.complete(p, args); }
}
}
@@ -0,0 +1,14 @@
package net.scopenet.core;
import java.util.List;
/** One slash command, the same on every platform. */
public interface CoreCommand {
String name();
default List<String> aliases() { return List.of(); }
/** Node checked before {@link #run}; platforms apply LuckPerms or their default. */
String permission();
void run(CorePlayer player, String[] args);
/** Tab-completion candidates for the last argument. */
default List<String> complete(CorePlayer player, String[] args) { return List.of(); }
}
@@ -1,5 +1,6 @@
package net.scopenet.core; package net.scopenet.core;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
/** What the shared logic needs from an online player. Each platform provides an adapter. */ /** What the shared logic needs from an online player. Each platform provides an adapter. */
@@ -7,9 +8,17 @@ public interface CorePlayer {
UUID uuid(); UUID uuid();
String name(); String name();
Pos pos(); Pos pos();
/** Send a chat message. Legacy section-sign colour codes are allowed. */ /** Send a chat message. Legacy section-sign colour codes are allowed. Safe to call from the server thread. */
void send(String message); void send(String message);
void teleport(Pos destination); void teleport(Pos destination);
/** True if the player may use {@code node}. Platforms map this onto LuckPerms or their own default. */ /** True if the player may use {@code node}. Platforms map this onto LuckPerms or their own default. */
boolean hasPermission(String node); boolean hasPermission(String node);
/** Total time played, in seconds. */
long playtimeSeconds();
/** The stack in the main hand, empty if none. */
Optional<Item> heldItem();
/** Remove whatever is in the main hand. */
void clearHeld();
/** Put the stack in the inventory, or drop it at the player's feet if full. */
void give(Item item);
} }
@@ -0,0 +1,192 @@
package net.scopenet.core;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.*;
/**
* /balance /pay /baltop /shop /sell /market /orders /transactions, in chat. Money lives in the panel; selling and buying
* go through {@link Jobs} so a crash or outage never loses an item or a payment. Shop, sell-chest and market GUIs
* are the client mod's job; the commands here are what it (and plain chat) use.
*/
final class EconomyCommands {
private final Env env;
private final Jobs jobs;
EconomyCommands(Env env, Jobs jobs) { this.env = env; this.jobs = jobs; }
List<CoreCommand> build() {
List<CoreCommand> out = new ArrayList<>();
out.add(Cmd.of("balance", List.of("bal", "money"), (p, a) -> balance(p)));
out.add(Cmd.of("pay", List.of(), this::pay).completing((p, a) -> a.length <= 1 ? EssentialsCommands.names(env, p) : List.of()));
out.add(Cmd.of("baltop", List.of("richest"), (p, a) -> baltop(p)));
out.add(Cmd.of("shop", List.of(), (p, a) -> shop(p)));
out.add(Cmd.of("sell", List.of(), (p, a) -> sell(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("hand") : List.of()));
out.add(Cmd.of("market", List.of("ah", "auction"), (p, a) -> market(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("sell", "buy") : List.of()));
out.add(Cmd.of("orders", List.of(), (p, a) -> orders(p)));
out.add(Cmd.of("trade", List.of(), (p, a) -> p.send(Format.RED + "/trade needs the chest window, which isn't available on this platform yet. Use /market or /pay.")));
out.add(Cmd.of("transactions", List.of(), (p, a) -> transactions(p)));
return out;
}
private JsonObject who(CorePlayer p) {
JsonObject o = new JsonObject();
o.addProperty("uuid", p.uuid().toString());
o.addProperty("username", p.name());
return o;
}
void balance(CorePlayer p) {
p.send(Format.GRAY + "Fetching your balance...");
env.io(() -> env.panel.call("economy/balance", who(p)).getAsJsonObject(), r -> {
double bal = r.has("balance") ? r.get("balance").getAsDouble() : 0.0;
p.send(Format.GOLD + "=== SCOPENET Economy ===");
p.send(Format.YELLOW + "Your Balance: " + Format.GREEN + env.money(bal));
}, e -> p.send(Format.RED + "Could not reach economy server: " + e));
}
void pay(CorePlayer p, String[] args) {
if (args.length < 2) { p.send(Format.RED + "Usage: /pay <player> <amount>"); return; }
Optional<CorePlayer> found = env.platform.playerByName(args[0]);
if (found.isEmpty()) { p.send(Format.RED + "Player '" + args[0] + "' is not online."); return; }
CorePlayer target = found.get();
if (target.uuid().equals(p.uuid())) { p.send(Format.RED + "You cannot pay yourself."); return; }
Double amount;
try { amount = Double.parseDouble(args[1]); } catch (NumberFormatException e) { amount = null; }
if (amount == null || amount <= 0 || Double.isNaN(amount) || Double.isInfinite(amount)) {
p.send(Format.RED + "Invalid amount specified. Must be positive number.");
return;
}
final double value = amount;
p.send(Format.GRAY + "Processing payment...");
JsonObject body = new JsonObject();
body.addProperty("from_uuid", p.uuid().toString());
body.addProperty("from_name", p.name());
body.addProperty("to_uuid", target.uuid().toString());
body.addProperty("to_name", target.name());
body.addProperty("amount", value);
body.addProperty("description", "Player payment to " + target.name());
env.io(() -> env.panel.call("economy/transfer", body).getAsJsonObject(), r -> {
double left = r.has("from_balance") ? r.get("from_balance").getAsDouble() : 0.0;
p.send(Format.GREEN + "Sent " + Format.YELLOW + env.money(value) + Format.GREEN + " to " + Format.YELLOW + target.name()
+ Format.GREEN + ". New Balance: " + Format.YELLOW + env.money(left));
target.send(Format.GREEN + "Received " + Format.YELLOW + env.money(value) + Format.GREEN + " from " + Format.YELLOW + p.name() + Format.GREEN + "!");
}, e -> p.send(Format.RED + "Transfer failed: " + e));
}
void baltop(CorePlayer p) {
p.send(Format.GRAY + "Loading leaderboard...");
env.io(() -> env.panel.call("economy/baltop", new JsonObject()), el -> {
JsonArray list = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
p.send(Format.GOLD + "================= " + Format.YELLOW + "BALTOP (Richest Players)" + Format.GOLD + " =================");
if (list.size() == 0) { p.send(Format.GRAY + "No players found on the leaderboard."); return; }
for (int i = 0; i < Math.min(10, list.size()); i++) {
JsonObject e = list.get(i).getAsJsonObject();
int rank = e.has("rank") ? e.get("rank").getAsInt() : i + 1;
String name = e.has("username") ? e.get("username").getAsString() : "Unknown";
double bal = e.has("balance") ? e.get("balance").getAsDouble() : 0.0;
String colour = rank == 1 ? Format.GOLD : rank == 2 ? Format.WHITE : rank == 3 ? Format.YELLOW : Format.GRAY;
p.send(colour + "#" + rank + " " + Format.WHITE + name + ": " + Format.GREEN + env.money(bal));
}
}, e -> p.send(Format.RED + "Could not fetch baltop: " + e));
}
void shop(CorePlayer p) {
p.send(Format.GOLD + "=== Server Shop (sell prices) ===");
ItemValues.all().forEach((id, price) -> p.send(Format.GRAY + " " + id.toLowerCase(Locale.ROOT).replace('_', ' ') + Format.DARK_GRAY + " - " + Format.GREEN + env.money(price)));
p.send(Format.GRAY + "Hold items and use " + Format.YELLOW + "/sell hand" + Format.GRAY + " to sell them.");
}
/** {@code guild}: the proceeds go to the player's guild bank. */
void sell(CorePlayer p, String[] args, boolean guild) {
if (args.length == 0 || !args[0].equalsIgnoreCase("hand")) {
p.send(Format.YELLOW + "Hold the items you want to sell and use /" + (guild ? "guild sell" : "sell") + " hand." + Format.GRAY + " See prices with /shop.");
return;
}
if (!guild && !p.hasPermission("scopenet.command.sell.hand")) { p.send(Format.RED + "You do not have permission to use /sell hand."); return; }
Optional<Item> held = p.heldItem();
if (held.isEmpty()) { p.send(Format.RED + "You are not holding any item to sell."); return; }
Item item = held.get();
Double unit = ItemValues.unitPrice(item.id());
if (unit == null) { p.send(Format.RED + "This item (" + item.id() + ") cannot be sold to the server shop."); return; }
JsonObject payload = who(p);
payload.addProperty(guild ? "amount" : "delta", unit * item.count());
payload.addProperty("description", "Sell hand: " + item.id());
p.clearHeld();
if (!jobs.enqueue(p, guild ? "guilds/bank/credit" : "economy/adjust", payload, List.of(item), null)) p.give(item);
}
/** {@code asGuild}: list on behalf of the player's guild, or buy with its bank. */
void market(CorePlayer p, String[] args, boolean asGuild) {
if (args.length >= 2 && args[0].equalsIgnoreCase("buy")) {
if (!asGuild && !p.hasPermission("scopenet.command.market.buy")) { p.send(Format.RED + "You do not have permission to buy from the market."); return; }
long listing;
try { listing = Long.parseLong(args[1].replace("#", "")); } catch (NumberFormatException e) {
p.send(Format.RED + "Usage: /" + (asGuild ? "guild market" : "market") + " buy <listing number> (shown as #n in /market)");
return;
}
JsonObject payload = new JsonObject();
payload.addProperty("listing_id", listing);
payload.addProperty("buyer_uuid", p.uuid().toString());
payload.addProperty("buyer_name", p.name());
if (asGuild) payload.addProperty("as_guild", true);
jobs.enqueue(p, "economy/market/buy", payload, List.of(), null);
return;
}
if (args.length >= 2 && args[0].equalsIgnoreCase("sell")) {
if (!asGuild && !p.hasPermission("scopenet.command.market.sell")) { p.send(Format.RED + "You do not have permission to list items on the market."); return; }
Optional<Item> held = p.heldItem();
if (held.isEmpty()) { p.send(Format.RED + "Hold the item you want to sell in your main hand."); return; }
Double price;
try { price = Double.parseDouble(args[1]); } catch (NumberFormatException e) { price = null; }
if (price == null || price <= 0 || price.isNaN() || price.isInfinite()) { p.send(Format.RED + "Invalid price. Usage: /market sell <price>"); return; }
Item item = held.get();
JsonObject payload = new JsonObject();
payload.addProperty("seller_uuid", p.uuid().toString());
payload.addProperty("seller_name", p.name());
payload.addProperty("item_id", item.id());
payload.addProperty("item_name", item.name());
payload.addProperty("amount", item.count());
payload.addProperty("price", price);
payload.addProperty("item_data", item.data());
if (asGuild) payload.addProperty("as_guild", true);
p.clearHeld(); // escrow first, then a retryable operation
if (!jobs.enqueue(p, "economy/market/list", payload, List.of(item), null)) p.give(item);
return;
}
listMarket(p);
}
void listMarket(CorePlayer p) {
p.send(Format.GRAY + "Loading market listings...");
env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> {
JsonArray list = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray();
p.send(Format.GOLD + "=== Player Market ===");
if (list.size() == 0) { p.send(Format.GRAY + "No listings. Be the first: hold an item and use /market sell <price>."); return; }
for (JsonElement e : list) {
JsonObject l = e.getAsJsonObject();
String guild = l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? l.get("seller_guild").getAsString() : null;
String seller = guild != null ? Format.GOLD + "[" + guild + "] guild" : Format.WHITE + l.get("seller_name").getAsString();
p.send(Format.DARK_GRAY + "#" + l.get("id").getAsLong() + " " + Format.WHITE + l.get("item_name").getAsString() + Format.GRAY + " x" + l.get("amount").getAsInt()
+ " " + Format.GREEN + env.money(l.get("price").getAsDouble()) + Format.GRAY + " by " + seller);
}
p.send(Format.GRAY + "Buy with " + Format.YELLOW + "/market buy <#id>");
}, e -> p.send(Format.RED + "Could not load market listings: " + e));
}
void orders(CorePlayer p) {
p.send(Format.GOLD + "=== Your Active Market Orders ===");
p.send(Format.GRAY + "Use " + Format.YELLOW + "/market sell <price>" + Format.GRAY + " to list items, or browse with " + Format.YELLOW + "/market" + Format.GRAY + ".");
}
void transactions(CorePlayer p) {
p.send(Format.GOLD + "=== Recent Transactions ===");
env.io(() -> env.panel.call("economy/balance", who(p)).getAsJsonObject(), r -> {
double bal = r.has("balance") ? r.get("balance").getAsDouble() : 0.0;
p.send(Format.GREEN + "Current Balance: " + Format.YELLOW + env.money(bal));
p.send(Format.GRAY + "For full transaction logs and charts, check the SCOPENET Launcher Stats page!");
}, e -> p.send(Format.RED + "Error: " + e));
}
}
@@ -0,0 +1,39 @@
package net.scopenet.core;
import java.nio.file.Path;
import java.util.Random;
import java.util.concurrent.Callable;
import java.util.function.Consumer;
import java.util.function.LongSupplier;
import java.util.logging.Logger;
/** Everything a command needs: the platform, the panel, settings and a way to do network work safely. */
public final class Env {
public final Platform platform;
public final Panel panel;
public volatile Features features;
public final Path dataDir;
public final LongSupplier clock;
public final Random rng;
public final Logger log;
public Env(Platform platform, Panel panel, Features features, Path dataDir, LongSupplier clock, Random rng, Logger log) {
this.platform = platform; this.panel = panel; this.features = features; this.dataDir = dataDir;
this.clock = clock; this.rng = rng; this.log = log;
}
/** Do {@code work} off the server thread, then call back on it with the result or an error message. */
public <T> void io(Callable<T> work, Consumer<T> ok, Consumer<String> fail) {
platform.runAsync(() -> {
try {
T result = work.call();
platform.runMain(() -> ok.accept(result));
} catch (Exception e) {
String message = e.getMessage() != null && !e.getMessage().isBlank() ? e.getMessage() : e.getClass().getSimpleName();
platform.runMain(() -> fail.accept(message));
}
});
}
public String money(double value) { return Format.money(features.currencySymbol(), value); }
}
@@ -0,0 +1,51 @@
package net.scopenet.core;
import java.util.*;
/** /spawn /home /sethome /delhome /back /tpa /tpaccept /tpdeny /rtp /warp /playtime. */
final class EssentialsCommands {
private EssentialsCommands() {}
static List<CoreCommand> build(Env env, EssentialsService ess) {
List<CoreCommand> out = new ArrayList<>();
out.add(Cmd.of("spawn", List.of("hub", "lobby"), (p, a) -> {
Pos spawn = env.platform.worldSpawn(p.pos().world());
ess.teleportTo(p, new Pos(spawn.world(), spawn.x() + 0.5, spawn.y(), spawn.z() + 0.5, spawn.yaw(), spawn.pitch()));
p.send(Format.GREEN + "Teleported to spawn.");
}));
out.add(Cmd.of("home", List.of("homes"), (p, a) -> {
List<String> choices = ess.home(p, a);
if (!choices.isEmpty()) p.send(Format.GOLD + "Your homes: " + Format.YELLOW + String.join(Format.GRAY + ", " + Format.YELLOW, choices)
+ Format.GRAY + " (use /home <name>)");
}).completing((p, a) -> a.length <= 1 ? ess.homeNames(p.uuid()) : List.of()));
out.add(Cmd.of("sethome", List.of("createshome"), ess::setHome));
out.add(Cmd.of("delhome", List.of("rmhome"), ess::delHome).completing((p, a) -> a.length <= 1 ? ess.homeNames(p.uuid()) : List.of()));
out.add(Cmd.of("back", List.of("return"), (p, a) -> ess.back(p)));
out.add(Cmd.of("tpa", List.of(), ess::tpa).completing((p, a) -> a.length <= 1 ? names(env, p) : List.of()));
out.add(Cmd.of("tpaccept", List.of("tpyes"), (p, a) -> ess.tpAccept(p)));
out.add(Cmd.of("tpdeny", List.of("tpno"), (p, a) -> ess.tpDeny(p)));
out.add(Cmd.of("rtp", List.of("wild", "randomtp"), (p, a) -> ess.rtp(p)));
out.add(Cmd.of("warp", List.of("warps"), (p, a) -> {
List<String> choices = ess.warp(p, a);
if (!choices.isEmpty()) p.send(Format.AQUA + "Warps: " + Format.YELLOW + String.join(Format.GRAY + ", " + Format.YELLOW, choices)
+ Format.GRAY + " (use /warp <name>)");
}).completing((p, a) -> a.length <= 1 ? ess.warpNames() : List.of()));
out.add(Cmd.of("playtime", List.of("ontime"), (p, a) -> {
CorePlayer target = p;
if (a.length > 0) {
Optional<CorePlayer> found = env.platform.playerByName(a[0]);
if (found.isEmpty()) { p.send(Format.RED + "Player not found."); return; }
target = found.get();
}
p.send(Format.GOLD + "=== Playtime: " + Format.YELLOW + target.name() + Format.GOLD + " ===");
p.send(Format.GRAY + "Total Time: " + Format.GREEN + Format.duration(target.playtimeSeconds()));
}).completing((p, a) -> a.length <= 1 ? names(env, p) : List.of()));
return out;
}
static List<String> names(Env env, CorePlayer except) {
List<String> names = new ArrayList<>();
for (CorePlayer p : env.platform.online()) if (!p.uuid().equals(except.uuid())) names.add(p.name());
return names;
}
}
@@ -229,6 +229,9 @@ public final class EssentialsService {
p.send(RED + "Could not find a safe RTP spot. Please try again."); p.send(RED + "Could not find a safe RTP spot. Please try again.");
} }
/** Teleport and remember where they were for /back. */
public void teleportTo(CorePlayer p, Pos destination) { go(p, destination); }
private void go(CorePlayer p, Pos destination) { private void go(CorePlayer p, Pos destination) {
remember(p.uuid(), p.pos()); remember(p.uuid(), p.pos());
p.teleport(destination); p.teleport(destination);
@@ -0,0 +1,7 @@
package net.scopenet.core;
/** Which systems are switched on, plus small display settings. */
public record Features(boolean essentials, boolean economy, boolean guilds, boolean social, boolean landClaiming,
boolean clientLink, String currencySymbol) {
public static Features all() { return new Features(true, true, true, true, true, true, "$"); }
}
@@ -0,0 +1,29 @@
package net.scopenet.core;
import java.util.Locale;
/** Text helpers shared by every command. Colour codes are legacy section signs. */
public final class Format {
public static final String GOLD = "§6", YELLOW = "§e", GREEN = "§a", RED = "§c", GRAY = "§7",
WHITE = "§f", AQUA = "§b", DARK_GRAY = "§8";
private Format() {}
public static String money(String symbol, double value) { return symbol + String.format(Locale.US, "%,.2f", value); }
/** Positive amount with at most two decimals, or null. */
public static Double amount(String text) {
try {
double value = Double.parseDouble(text.replace(",", ""));
if (Double.isNaN(value) || Double.isInfinite(value) || value < 0.01) return null;
return Math.round(value * 100.0) / 100.0;
} catch (NumberFormatException e) {
return null;
}
}
public static String plain(String withColours) { return withColours.replaceAll("§.", ""); }
public static String duration(long seconds) {
return (seconds / 3600) + "h " + ((seconds % 3600) / 60) + "m " + (seconds % 60) + "s";
}
}
@@ -0,0 +1,302 @@
package net.scopenet.core;
import com.google.gson.*;
import net.scopenet.integration.ChunkCheckResult;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/** /guild (and its subcommands), /claim, /unclaim: same behaviour and permission nodes as the Paper plugin. */
final class GuildCommands {
private static final Gson GSON = new Gson();
private static final Set<String> GATED = Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay");
private final Env env;
private final Jobs jobs;
private final EconomyCommands economy;
private final EssentialsService ess;
private final Path homesFile;
private final Map<String, Pos> guildHomes = new ConcurrentHashMap<>();
GuildCommands(Env env, Jobs jobs, EconomyCommands economy, EssentialsService ess, Path homesFile) {
this.env = env; this.jobs = jobs; this.economy = economy; this.ess = ess; this.homesFile = homesFile;
if (Files.exists(homesFile)) {
try (Reader in = Files.newBufferedReader(homesFile)) {
Map<String, Pos> loaded = GSON.fromJson(in, new com.google.gson.reflect.TypeToken<Map<String, Pos>>() {}.getType());
if (loaded != null) guildHomes.putAll(loaded);
} catch (IOException | RuntimeException e) {
throw new IllegalStateException("Could not read " + homesFile + ": " + e.getMessage(), e);
}
}
}
List<CoreCommand> build() {
List<CoreCommand> out = new ArrayList<>();
out.add(Cmd.of("guild", List.of("g", "clan"), this::guild).completing((p, a) -> a.length <= 1
? List.of("info", "create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay") : List.of()));
out.add(Cmd.of("claim", List.of(), (p, a) -> claim(p)));
out.add(Cmd.of("unclaim", List.of(), (p, a) -> unclaim(p)));
return out;
}
private void guild(CorePlayer p, String[] args) {
if (!env.features.guilds()) { p.send(Format.RED + "Guilds are disabled."); return; }
if (args.length == 0 || args[0].equalsIgnoreCase("info") || args[0].equalsIgnoreCase("gui")) {
if (!p.hasPermission("scopenet.command.guild.info")) { p.send(Format.RED + "You do not have permission to use /guild info."); return; }
info(p);
return;
}
String sub = args[0].toLowerCase(Locale.ROOT);
String node = sub.equals("c") ? "chat" : sub;
if (GATED.contains(node) && !p.hasPermission("scopenet.command.guild." + node)) {
p.send(Format.RED + "You do not have permission to use /guild " + node + ".");
return;
}
String detail = args.length > 1 ? args[1].toLowerCase(Locale.ROOT) : "";
String fine = sub.equals("sell") && detail.equals("hand") ? "sell.hand"
: sub.equals("market") && (detail.equals("sell") || detail.equals("buy")) ? "market." + detail : null;
if (fine != null && !p.hasPermission("scopenet.command.guild." + fine)) {
p.send(Format.RED + "You do not have permission to use /guild " + sub + " " + detail + ".");
return;
}
String[] rest = Arrays.copyOfRange(args, 1, args.length);
switch (sub) {
case "create" -> create(p, args);
case "leave" -> leave(p);
case "claim" -> claim(p);
case "unclaim" -> unclaim(p);
case "map" -> map(p);
case "chat", "c" -> chat(p, args);
case "sethome" -> setHome(p);
case "home" -> home(p);
case "members" -> members(p);
case "bank" -> bank(p, rest);
case "pay" -> pay(p, rest);
case "sell" -> economy.sell(p, rest, true);
case "market" -> economy.market(p, rest, true);
default -> help(p);
}
}
private void help(CorePlayer p) {
p.send(Format.GOLD + "=== SCOPENET Guild Commands ===");
String[][] rows = {
{"/guild", "Your guild overview"}, {"/guild create <name> <tag>", "Create a new guild"}, {"/guild leave", "Leave your current guild"},
{"/claim", "Claim current chunk (also /guild claim)"}, {"/unclaim", "Unclaim current chunk (also /guild unclaim)"},
{"/guild map", "Show nearby land claims"}, {"/guild chat <msg>", "Send message to guild members"},
{"/guild sethome / /guild home", "Guild waypoint base"}, {"/guild bank [deposit|withdraw <amount>]", "Guild money"},
{"/guild sell [hand]", "Sell items to the shop for the guild bank"}, {"/guild market sell <price> | buy <#id>", "Trade on the market as a guild"},
{"/guild pay <tag> <amount>", "Pay another guild"},
};
for (String[] r : rows) p.send(Format.YELLOW + r[0] + Format.GRAY + " - " + r[1]);
}
private JsonObject uuidBody(CorePlayer p) {
JsonObject o = new JsonObject();
o.addProperty("uuid", p.uuid().toString());
return o;
}
private JsonObject chunkBody(CorePlayer p) {
Pos pos = p.pos();
JsonObject o = uuidBody(p);
o.addProperty("dimension", pos.world());
o.addProperty("chunk_x", pos.blockX() >> 4);
o.addProperty("chunk_z", pos.blockZ() >> 4);
return o;
}
void claim(CorePlayer p) {
if (!env.features.guilds()) { p.send(Format.RED + "Guilds are disabled."); return; }
JsonObject body = chunkBody(p);
p.send(Format.GRAY + "Claiming chunk [" + body.get("chunk_x").getAsInt() + ", " + body.get("chunk_z").getAsInt() + "]...");
env.io(() -> { JsonObject r = env.panel.call("guilds/claim", body).getAsJsonObject(); env.panel.claimsChanged(); return r; }, r -> {
String name = r.has("guild_name") ? r.get("guild_name").getAsString() : "your guild";
p.send(Format.GREEN + "Chunk claimed successfully for " + Format.YELLOW + name + Format.GREEN + "!");
}, e -> p.send(Format.RED + "Claim failed: " + e));
}
void unclaim(CorePlayer p) {
if (!env.features.guilds()) { p.send(Format.RED + "Guilds are disabled."); return; }
JsonObject body = chunkBody(p);
int cx = body.get("chunk_x").getAsInt(), cz = body.get("chunk_z").getAsInt();
p.send(Format.GRAY + "Unclaiming chunk [" + cx + ", " + cz + "]...");
env.io(() -> { JsonObject r = env.panel.call("guilds/unclaim", body).getAsJsonObject(); env.panel.claimsChanged(); return r; },
r -> p.send(Format.GREEN + "Chunk [" + cx + ", " + cz + "] has been unclaimed."), e -> p.send(Format.RED + "Unclaim failed: " + e));
}
private void create(CorePlayer p, String[] args) {
if (args.length < 3) { p.send(Format.RED + "Usage: /guild create <name> <tag>"); return; }
String name = args[1], tag = args[2];
p.send(Format.GRAY + "Founding guild " + name + " [" + tag + "]...");
JsonObject body = uuidBody(p);
body.addProperty("username", p.name());
body.addProperty("name", name);
body.addProperty("tag", tag);
env.io(() -> { JsonObject r = env.panel.call("guilds/create", body).getAsJsonObject(); env.panel.claimsChanged(); return r; },
r -> p.send(Format.GREEN + "Guild " + Format.YELLOW + name + Format.GOLD + " [" + tag + "]" + Format.GREEN + " founded successfully! You are the Guild Leader."),
e -> p.send(Format.RED + "Failed to create guild: " + e));
}
private void leave(CorePlayer p) {
p.send(Format.GRAY + "Leaving guild...");
env.io(() -> { JsonObject r = env.panel.call("guilds/leave", uuidBody(p)).getAsJsonObject(); env.panel.claimsChanged(); return r; }, r -> {
boolean disbanded = r.has("disbanded") && r.get("disbanded").getAsBoolean();
p.send(disbanded ? Format.YELLOW + "You were the last member. The guild was disbanded." : Format.GREEN + "You left your guild.");
}, e -> p.send(Format.RED + "Error leaving guild: " + e));
}
/** The guild the player is in, or an error message sent to them. Runs {@code then} on the server thread. */
private void withGuild(CorePlayer p, String notInGuild, java.util.function.Consumer<JsonObject> then) {
env.io(() -> env.panel.call("guilds/player", uuidBody(p)).getAsJsonObject(), r -> {
if (!r.has("in_guild") || !r.get("in_guild").getAsBoolean()) { p.send(Format.RED + notInGuild); return; }
then.accept(r.getAsJsonObject("guild"));
}, e -> p.send(Format.RED + "Error: " + e));
}
private void info(CorePlayer p) {
p.send(Format.GRAY + "Loading guild info...");
env.io(() -> env.panel.call("guilds/player", uuidBody(p)).getAsJsonObject(), r -> {
if (!r.has("in_guild") || !r.get("in_guild").getAsBoolean()) {
p.send(Format.YELLOW + "You are not in a guild! Create one with " + Format.GOLD + "/guild create <name> <tag>");
return;
}
JsonObject g = r.getAsJsonObject("guild");
int members = g.has("members") ? g.getAsJsonArray("members").size() : 0;
p.send(Format.GOLD + "=== Guild: " + Format.YELLOW + g.get("name").getAsString() + " " + Format.GOLD + "[" + g.get("tag").getAsString() + "] ===");
p.send(Format.GRAY + "Your role: " + Format.GREEN + g.get("role").getAsString().toUpperCase(Locale.ROOT) + Format.GRAY + " Level: " + Format.GOLD
+ (g.has("level") ? g.get("level").getAsInt() : 1) + Format.GRAY + " Members: " + Format.WHITE + members);
p.send(Format.GRAY + "Claimed chunks: " + Format.YELLOW + (g.has("claims_count") ? g.get("claims_count").getAsLong() : 0)
+ Format.GRAY + " / " + Format.GREEN + (g.has("max_claims") ? g.get("max_claims").getAsLong() : 16));
p.send(Format.GRAY + "/claim, /unclaim, /guild map, /guild members, /guild bank");
}, e -> p.send(Format.RED + "Failed to load guild: " + e));
}
private void map(CorePlayer p) {
Pos pos = p.pos();
int cx = pos.blockX() >> 4, cz = pos.blockZ() >> 4;
p.send(Format.GOLD + "========= " + Format.YELLOW + "Territory Map (" + cx + ", " + cz + ")" + Format.GOLD + " =========");
p.send(Format.GRAY + " N (-Z)");
for (int dz = -3; dz <= 3; dz++) {
StringBuilder row = new StringBuilder(" ");
for (int dx = -4; dx <= 4; dx++) {
if (dx == 0 && dz == 0) { row.append(Format.GOLD).append("[P]"); continue; }
ChunkCheckResult res = env.panel.check(pos.world(), cx + dx, cz + dz, p.uuid());
if (res.claimed() && res.allowed()) row.append(Format.GREEN).append(" + ");
else if (res.claimed()) row.append(Format.RED).append(" x ");
else row.append(Format.DARK_GRAY).append(" . ");
}
p.send(row.toString());
}
p.send(Format.GRAY + " S (+Z)");
p.send(Format.GOLD + "[P]" + Format.YELLOW + " You " + Format.GREEN + "+ " + Format.GRAY + "Your Guild " + Format.RED + "x " + Format.GRAY + "Other Guild "
+ Format.DARK_GRAY + ". " + Format.GRAY + "Wilderness");
}
private void chat(CorePlayer p, String[] args) {
if (args.length < 2) { p.send(Format.RED + "Usage: /guild chat <message>"); return; }
String message = String.join(" ", Arrays.copyOfRange(args, 1, args.length));
withGuild(p, "You are not in a guild.", g -> {
String tag = g.get("tag").getAsString();
Set<String> names = new HashSet<>();
if (g.has("members")) g.getAsJsonArray("members").forEach(m -> names.add(m.getAsJsonObject().get("name").getAsString().toLowerCase(Locale.ROOT)));
String line = Format.GREEN + "[Guild " + tag + "] " + Format.YELLOW + p.name() + ": " + Format.WHITE + message;
// Only guild members read guild chat.
for (CorePlayer other : env.platform.online()) if (names.contains(other.name().toLowerCase(Locale.ROOT)) || other.uuid().equals(p.uuid())) other.send(line);
});
}
private void save() {
try {
if (homesFile.getParent() != null) Files.createDirectories(homesFile.getParent());
Path tmp = homesFile.resolveSibling(homesFile.getFileName() + ".tmp");
try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(new TreeMap<>(guildHomes), w); }
Files.move(tmp, homesFile, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
env.log.warning("Failed to save guild homes: " + e.getMessage());
}
}
private void setHome(CorePlayer p) {
withGuild(p, "You must be in a guild to set a guild home.", g -> {
String role = g.get("role").getAsString();
if (!role.equals("leader") && !role.equals("officer")) { p.send(Format.RED + "Only guild leaders and officers can set the guild home."); return; }
guildHomes.put(g.get("id").getAsString(), p.pos());
save();
p.send(Format.GREEN + "Guild home waypoint set at your location!");
});
}
private void home(CorePlayer p) {
withGuild(p, "You are not in a guild.", g -> {
Pos at = guildHomes.get(g.get("id").getAsString());
if (at == null) { p.send(Format.RED + "Your guild has not set a guild home yet! Use /guild sethome"); return; }
ess.teleportTo(p, at);
p.send(Format.GREEN + "Teleported to guild home base.");
});
}
private void members(CorePlayer p) {
withGuild(p, "You are not in a guild.", g -> {
p.send(Format.GOLD + "=== Members of " + Format.YELLOW + g.get("name").getAsString() + Format.GOLD + " ===");
for (JsonElement el : g.getAsJsonArray("members")) {
JsonObject m = el.getAsJsonObject();
String name = m.get("name").getAsString();
String status = env.platform.playerByName(name).isPresent() ? Format.GREEN + "[Online]" : Format.GRAY + "[Offline]";
p.send(Format.WHITE + " - " + name + " (" + Format.AQUA + m.get("role").getAsString() + Format.WHITE + ") " + status);
}
});
}
// ---- bank ---------------------------------------------------------------
private void bank(CorePlayer p, String[] args) {
if (args.length >= 2 && (args[0].equalsIgnoreCase("deposit") || args[0].equalsIgnoreCase("withdraw"))) {
String action = args[0].toLowerCase(Locale.ROOT);
if (!p.hasPermission("scopenet.command.guild.bank." + action)) { p.send(Format.RED + "You do not have permission to " + action + " guild money."); return; }
Double value = Format.amount(args[1]);
if (value == null) { p.send(Format.RED + "Enter an amount of at least 0.01, for example 25 or 12.50."); return; }
JsonObject payload = uuidBody(p);
payload.addProperty("username", p.name());
payload.addProperty("amount", value);
payload.addProperty("action", action);
jobs.enqueue(p, "guilds/bank/transfer", payload, List.of(), null);
return;
}
if (args.length > 0 && !args[0].equalsIgnoreCase("balance") && !args[0].equalsIgnoreCase("info")) {
p.send(Format.RED + "Usage: /guild bank [deposit|withdraw <amount>]");
return;
}
env.io(() -> env.panel.call("guilds/bank", uuidBody(p)).getAsJsonObject(), info -> {
if (!info.has("guild") || info.get("guild").isJsonNull()) { p.send(Format.RED + "You are not in a guild on this server."); return; }
JsonObject g = info.getAsJsonObject("guild");
p.send(Format.GOLD + "=== [" + g.get("tag").getAsString() + "] " + g.get("name").getAsString() + " Bank ===");
p.send(Format.YELLOW + "Balance: " + Format.GREEN + env.money(info.get("balance").getAsDouble()) + Format.GRAY + " Your wallet: "
+ env.money(info.get("my_balance").getAsDouble()) + " Role: " + info.get("role").getAsString());
for (JsonElement el : info.getAsJsonArray("recent")) {
JsonObject row = el.getAsJsonObject();
String kind = row.get("kind").getAsString();
boolean out = kind.equals("withdraw") || kind.equals("purchase") || kind.equals("transfer_out");
p.send(Format.GRAY + " " + (out ? Format.RED + "-" : Format.GREEN + "+") + env.money(row.get("amount").getAsDouble()) + Format.GRAY + " "
+ kind.replace('_', ' ') + " by " + row.get("who").getAsString());
}
p.send(Format.GRAY + "/guild bank deposit|withdraw <amount>, /guild sell, /guild market, /guild pay <tag> <amount>");
}, e -> p.send(Format.RED + "Could not load the guild bank: " + e));
}
private void pay(CorePlayer p, String[] args) {
if (args.length < 2) { p.send(Format.RED + "Usage: /guild pay <guild tag> <amount>"); return; }
Double value = Format.amount(args[1]);
if (value == null) { p.send(Format.RED + "Enter an amount of at least 0.01, for example 25 or 12.50."); return; }
JsonObject payload = uuidBody(p);
payload.addProperty("to_tag", args[0]);
payload.addProperty("amount", value);
jobs.enqueue(p, "guilds/bank/pay", payload, List.of(), null);
}
}
@@ -0,0 +1,19 @@
package net.scopenet.core;
/**
* A stack of items in a platform-neutral form. {@code id} is what the panel stores ("DIAMOND", or
* "modid:item" for modded items); {@code data} is the platform's own exact serialisation (used to return
* or deliver the very same stack). When {@code data} is empty the platform rebuilds the stack from id and count.
*/
public record Item(String id, String name, int count, String data) {
/** Panel id for a registry id: minecraft items become "DIAMOND", others stay "modid:item". */
public static String panelId(String registryId) {
String s = registryId.toLowerCase(java.util.Locale.ROOT);
return s.startsWith("minecraft:") ? s.substring("minecraft:".length()).toUpperCase(java.util.Locale.ROOT) : s;
}
/** The registry id for a panel id (inverse of {@link #panelId}). */
public static String registryId(String panelId) {
return panelId.contains(":") ? panelId : "minecraft:" + panelId.toLowerCase(java.util.Locale.ROOT);
}
}
@@ -0,0 +1,26 @@
package net.scopenet.core;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
/** What the server shop pays per item (same table as the Paper plugin). Keys are panel item ids. */
public final class ItemValues {
private static final Map<String, Double> VALUES = new TreeMap<>();
static {
Object[][] table = {
{"COBBLESTONE", 0.1}, {"STONE", 0.2}, {"OAK_LOG", 0.5}, {"BIRCH_LOG", 0.5}, {"SPRUCE_LOG", 0.5}, {"COAL", 1.0},
{"RAW_COPPER", 1.5}, {"COPPER_INGOT", 2.0}, {"RAW_IRON", 3.0}, {"IRON_INGOT", 4.0}, {"RAW_GOLD", 6.0},
{"GOLD_INGOT", 8.0}, {"REDSTONE", 1.5}, {"LAPIS_LAZULI", 2.0}, {"DIAMOND", 35.0}, {"EMERALD", 20.0},
{"NETHERITE_SCRAP", 150.0}, {"NETHERITE_INGOT", 650.0}, {"WHEAT", 0.5}, {"CARROT", 0.5}, {"POTATO", 0.5},
{"BEEF", 1.0}, {"COOKED_BEEF", 2.0}, {"PORKCHOP", 1.0}, {"COOKED_PORKCHOP", 2.0},
};
for (Object[] row : table) VALUES.put((String) row[0], (Double) row[1]);
}
private ItemValues() {}
public static Double unitPrice(String panelId) { return VALUES.get(panelId.toUpperCase(Locale.ROOT)); }
public static Map<String, Double> all() { return java.util.Collections.unmodifiableMap(VALUES); }
}
@@ -0,0 +1,158 @@
package net.scopenet.core;
import com.google.gson.*;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
/**
* Money-and-items transactions that must never lose either. The held items are taken and written to disk first
* ("escrow"), the panel call carries an operation id so a retry can't double-pay, and the items are only
* returned or delivered once the panel has answered. Survives crashes and panel outages. Server thread only.
*/
public final class Jobs {
private final Env env;
private final Path file;
private final Map<String, JsonObject> jobs = new LinkedHashMap<>();
private final Set<String> running = new HashSet<>();
public Jobs(Env env, Path file) {
this.env = env;
this.file = file;
if (Files.exists(file)) {
try (Reader in = Files.newBufferedReader(file)) {
JsonObject all = JsonParser.parseReader(in).getAsJsonObject();
all.entrySet().forEach(e -> jobs.put(e.getKey(), e.getValue().getAsJsonObject()));
} catch (IOException | RuntimeException e) {
throw new IllegalStateException("Could not read " + file + ": " + e.getMessage(), e);
}
}
}
public int pending() { return jobs.size(); }
private static JsonObject encode(Item item) {
JsonObject o = new JsonObject();
o.addProperty("id", item.id());
o.addProperty("name", item.name());
o.addProperty("count", item.count());
o.addProperty("data", item.data());
return o;
}
private static Item decode(JsonElement e) {
JsonObject o = e.getAsJsonObject();
return new Item(o.get("id").getAsString(), o.get("name").getAsString(), o.get("count").getAsInt(), o.get("data").getAsString());
}
/**
* Queue {@code endpoint} with {@code payload}. {@code escrow} were taken from the player and are returned if the
* panel refuses. {@code purchase} is delivered once it accepts. Returns false (and queues nothing) if it can't be saved.
*/
public boolean enqueue(CorePlayer player, String endpoint, JsonObject payload, List<Item> escrow, Item purchase) {
String id = UUID.randomUUID().toString();
payload.addProperty("operation_id", id);
JsonObject job = new JsonObject();
job.addProperty("player", player.uuid().toString());
job.addProperty("endpoint", endpoint);
job.add("payload", payload);
JsonArray held = new JsonArray();
escrow.forEach(i -> held.add(encode(i)));
job.add("escrow", held);
if (purchase != null) job.add("purchase", encode(purchase));
jobs.put(id, job);
try { save(); } catch (IOException e) {
jobs.remove(id);
player.send(Format.RED + "Cannot save transaction; nothing was purchased or sold.");
return false;
}
player.send(Format.GRAY + "Transaction queued. Items are kept safe while the panel responds.");
return true;
}
private void save() throws IOException {
JsonObject all = new JsonObject();
jobs.forEach(all::add);
if (file.getParent() != null) Files.createDirectories(file.getParent());
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
try (Writer w = Files.newBufferedWriter(tmp)) { w.write(all.toString()); }
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
/** Call every few seconds on the server thread. Sends waiting jobs and delivers finished ones. */
public void tick() {
for (Map.Entry<String, JsonObject> entry : new ArrayList<>(jobs.entrySet())) {
String id = entry.getKey();
JsonObject job = entry.getValue();
if (job.has("result") || job.has("error")) { deliver(id, job); continue; }
if (!running.add(id)) continue;
String endpoint = job.get("endpoint").getAsString();
JsonObject payload = job.getAsJsonObject("payload").deepCopy();
env.platform.runAsync(() -> {
JsonElement result = null;
String error = null;
try { result = env.panel.call(endpoint, payload); }
catch (net.scopenet.integration.PanelClient.HttpFailure e) { if (e.status >= 400 && e.status < 500) error = e.getMessage(); }
catch (Exception e) { /* Unknown outcome: retry the same operation id; a credit is never duplicated. */ }
final JsonElement response = result;
final String failure = error;
env.platform.runMain(() -> {
running.remove(id);
if (response != null && response.isJsonObject()) job.add("result", response);
if (failure != null) job.addProperty("error", failure);
try { save(); } catch (IOException e) { env.log.severe("Could not persist economy result: " + e.getMessage()); return; }
if (job.has("result") || job.has("error")) deliver(id, job);
});
});
}
}
private void deliver(String id, JsonObject job) {
Optional<CorePlayer> found = env.platform.player(UUID.fromString(job.get("player").getAsString()));
if (found.isEmpty()) return; // waits until they are back
CorePlayer player = found.get();
try {
List<Item> items = new ArrayList<>();
boolean failed = job.has("error");
String endpoint = job.get("endpoint").getAsString();
if (failed) {
job.getAsJsonArray("escrow").forEach(e -> items.add(decode(e)));
} else if (job.has("purchase")) {
items.add(decode(job.get("purchase")));
} else if (endpoint.equals("economy/market/buy")) {
JsonObject result = job.getAsJsonObject("result");
if (result.has("item_data") && !result.get("item_data").isJsonNull() && !result.get("item_data").getAsString().isEmpty()) {
// Another platform's exact stack can't be rebuilt here, so fall back to id and count.
items.add(new Item(result.get("item_id").getAsString(), result.get("item_id").getAsString(), result.get("amount").getAsInt(),
result.get("item_data").getAsString()));
} else {
items.add(new Item(result.get("item_id").getAsString(), result.get("item_id").getAsString(), result.get("amount").getAsInt(), ""));
}
}
items.forEach(player::give);
jobs.remove(id);
save();
String symbol = env.features.currencySymbol();
if (failed) {
player.send(Format.RED + "Transaction rejected; held items returned: " + job.get("error").getAsString());
return;
}
JsonObject result = job.getAsJsonObject("result");
if (result.has("message") && result.get("message").isJsonPrimitive()) {
JsonElement bal = result.get("balance");
String bank = bal != null && bal.isJsonPrimitive() ? " Bank: " + Format.money(symbol, bal.getAsDouble()) : "";
player.send(Format.GREEN + result.get("message").getAsString() + bank);
} else {
JsonElement bal = result.has("new_balance") ? result.get("new_balance") : result.get("balance");
player.send(Format.GREEN + "Transaction completed." + (bal != null && bal.isJsonPrimitive() ? " Balance: " + Format.money(symbol, bal.getAsDouble()) : ""));
}
} catch (Exception e) {
env.log.severe("Economy delivery requires attention for " + id + ": " + e.getMessage());
}
}
}
@@ -0,0 +1,18 @@
package net.scopenet.core;
import com.google.gson.JsonObject;
import net.scopenet.integration.ChunkCheckResult;
import java.util.UUID;
/** The panel calls the shared logic makes. {@link PanelAdapter} implements it over HTTP; tests use a fake. */
public interface Panel {
/** POST to {@code /api/server/v1/<endpoint>} and return the JSON answer. Throws with the panel's message on errors. */
com.google.gson.JsonElement call(String endpoint, JsonObject body) throws Exception;
/** Answered from the local claim index; never blocks on the network. */
/** Tell the claim index to re-sync soon (after claiming, unclaiming or founding a guild). */
default void claimsChanged() {}
ChunkCheckResult check(String dimension, int chunkX, int chunkZ, UUID player);
}
@@ -0,0 +1,22 @@
package net.scopenet.core;
import com.google.gson.JsonObject;
import net.scopenet.integration.ChunkCheckResult;
import net.scopenet.integration.Integration;
import java.util.UUID;
public final class PanelAdapter implements Panel {
private final Integration integration;
public PanelAdapter(Integration integration) { this.integration = integration; }
@Override public com.google.gson.JsonElement call(String endpoint, JsonObject body) throws Exception {
return integration.client().postElement(endpoint, body);
}
@Override public void claimsChanged() { integration.client().requestClaimRefresh(); }
@Override public ChunkCheckResult check(String dimension, int chunkX, int chunkZ, UUID player) {
return integration.checkChunk(dimension, chunkX, chunkZ, player);
}
}
@@ -1,16 +1,22 @@
package net.scopenet.core; package net.scopenet.core;
import java.util.Collection;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
/** The server side of the adapter: who is online and what the world looks like. */ /** The server side of the adapter: who is online, the world, and threads. */
public interface Platform { public interface Platform {
Optional<CorePlayer> player(UUID uuid); Optional<CorePlayer> player(UUID uuid);
Optional<CorePlayer> playerByName(String name); Optional<CorePlayer> playerByName(String name);
Collection<? extends CorePlayer> online();
/** /**
* The spot to stand on at (x, z): the top solid, non-liquid block. Empty if unsafe or unloadable. * The spot to stand on at (x, z): the top solid, non-liquid block. Empty if unsafe or unloadable.
* Called on the server thread. * Called on the server thread.
*/ */
Optional<Pos> safeSurface(String world, int x, int z); Optional<Pos> safeSurface(String world, int x, int z);
Pos worldSpawn(String world); Pos worldSpawn(String world);
/** Run on the server thread (may run immediately if already there). */
void runMain(Runnable task);
/** Run off the server thread, for network calls. */
void runAsync(Runnable task);
} }
@@ -0,0 +1,282 @@
package net.scopenet.core;
import com.google.gson.JsonObject;
import net.scopenet.integration.ChunkCheckResult;
import net.scopenet.integration.PanelClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class CommandsTest {
@TempDir Path dir;
Kit kit;
CommandSet set;
Kit.Player alex, steve;
@BeforeEach void setup() {
kit = new Kit(dir);
alex = kit.platform.add("Alex");
steve = kit.platform.add("Steve");
set = new CommandSet(kit.env, EssentialsConfig.defaults());
}
void run(Kit.Player p, String name, String... args) { Kit.find(set.all(), name).run(p, args); }
// ---- gating -------------------------------------------------------------------------
@Test void featureSwitchesAndPermissionsGateEveryCommand() {
alex.denied.add("scopenet.command.home");
run(alex, "home");
assertTrue(alex.last().contains("do not have permission"));
kit.env.features = new Features(false, true, true, true, true, true, "$");
run(steve, "sethome");
assertEquals("Essentials are disabled.", steve.last());
kit.env.features = new Features(true, false, true, true, true, true, "$");
run(steve, "balance");
assertEquals("Economy are disabled.", steve.last());
}
@Test void everyPaperCommandExists() {
var names = new java.util.HashSet<String>();
set.all().forEach(c -> names.add(c.name()));
for (String n : List.of("spawn", "home", "sethome", "delhome", "back", "tpa", "tpaccept", "tpdeny", "rtp", "warp", "playtime",
"balance", "pay", "baltop", "shop", "sell", "market", "orders", "trade", "transactions", "guild", "claim", "unclaim"))
assertTrue(names.contains(n), n);
assertTrue(Kit.find(set.all(), "balance").aliases().contains("bal"));
assertEquals("scopenet.command.guild", Kit.find(set.all(), "guild").permission());
}
// ---- essentials wiring ---------------------------------------------------------------
@Test void essentialsCommandsWork() {
run(alex, "spawn");
assertEquals(80, alex.pos.y());
run(alex, "playtime");
assertTrue(alex.last().contains("1h 1m 40s"));
run(alex, "playtime", "steve");
assertTrue(alex.heard("Playtime: Steve"));
run(alex, "sethome", "a");
run(alex, "sethome", "b");
run(alex, "home");
assertTrue(alex.last().contains("a, b"), alex.last());
assertEquals(List.of("a", "b"), Kit.find(set.all(), "home").complete(alex, new String[]{""}));
assertEquals(List.of("Steve"), Kit.find(set.all(), "tpa").complete(alex, new String[]{""}));
}
// ---- economy -------------------------------------------------------------------------
@Test void balancePayAndBaltop() {
kit.panel.on("economy/balance", "{\"balance\":1234.5}");
run(alex, "balance");
assertTrue(alex.heard("Your Balance: $1,234.50"));
kit.panel.on("economy/transfer", "{\"from_balance\":900.0}");
run(alex, "pay", "steve", "100");
assertTrue(alex.heard("Sent $100.00 to Steve. New Balance: $900.00"));
assertTrue(steve.heard("Received $100.00 from Alex"));
JsonObject sent = kit.panel.bodies.get(kit.panel.bodies.size() - 1);
assertEquals(steve.id.toString(), sent.get("to_uuid").getAsString());
run(alex, "pay", "steve", "-5");
assertTrue(alex.last().contains("Invalid amount"));
run(alex, "pay", "alex", "5");
assertTrue(alex.last().contains("yourself"));
run(alex, "pay", "ghost", "5");
assertTrue(alex.last().contains("not online"));
kit.panel.handlers.put("economy/transfer", b -> { throw new PanelClient.HttpFailure(400, "Insufficient funds"); });
run(alex, "pay", "steve", "1");
assertTrue(alex.last().contains("Transfer failed: Insufficient funds"));
kit.panel.on("economy/baltop", "[{\"rank\":1,\"username\":\"Mia\",\"balance\":9000},{\"rank\":2,\"username\":\"Alex\",\"balance\":5}]");
run(alex, "baltop");
assertTrue(alex.heard("#1 Mia: $9,000.00"));
}
@Test void sellHandIsEscrowedThenPaid() {
kit.panel.on("economy/adjust", "{\"balance\":70.0}");
alex.held = new Item("DIAMOND", "Diamond", 2, "");
run(alex, "sell", "hand");
assertNull(alex.held, "taken into escrow");
assertEquals(1, set.jobs.pending());
set.tick();
assertEquals(0, set.jobs.pending());
JsonObject body = kit.panel.bodies.get(0);
assertEquals(70.0, body.get("delta").getAsDouble());
assertTrue(body.has("operation_id"));
assertTrue(alex.heard("Transaction completed. Balance: $70.00"));
assertTrue(alex.inventory.isEmpty());
run(alex, "sell", "hand");
assertTrue(alex.last().contains("not holding"));
alex.held = new Item("DIRT", "Dirt", 1, "");
run(alex, "sell", "hand");
assertTrue(alex.last().contains("cannot be sold"));
}
@Test void rejectedSaleReturnsTheItemsAndOutagesRetryTheSameOperation() {
alex.held = new Item("DIAMOND", "Diamond", 1, "nbt");
List<String> ops = new java.util.ArrayList<>();
kit.panel.handlers.put("economy/adjust", b -> { ops.add(b.get("operation_id").getAsString()); throw new java.io.IOException("connection refused"); });
run(alex, "sell", "hand");
set.tick();
set.tick();
assertEquals(1, set.jobs.pending(), "outage keeps the job");
assertEquals(2, ops.size());
assertEquals(ops.get(0), ops.get(1), "same operation id on retry");
kit.panel.handlers.put("economy/adjust", b -> { throw new PanelClient.HttpFailure(400, "Sales are paused"); });
set.tick();
assertEquals(0, set.jobs.pending());
assertEquals(1, alex.inventory.size());
assertEquals("nbt", alex.inventory.get(0).data(), "the very same stack comes back");
assertTrue(alex.heard("Transaction rejected; held items returned: Sales are paused"));
}
@Test void escrowSurvivesARestart() {
alex.held = new Item("DIAMOND", "Diamond", 1, "");
run(alex, "sell", "hand");
CommandSet reopened = new CommandSet(kit.env, EssentialsConfig.defaults());
assertEquals(1, reopened.jobs.pending());
kit.panel.on("economy/adjust", "{\"balance\":35.0}");
reopened.tick();
assertEquals(0, reopened.jobs.pending());
assertTrue(alex.heard("Balance: $35.00"));
}
@Test void marketListBuyAndSell() {
kit.panel.on("economy/market", "[{\"id\":7,\"seller_name\":\"Mia\",\"seller_guild\":null,\"item_id\":\"DIAMOND\",\"item_name\":\"Diamond\",\"amount\":3,\"price\":99.5}]");
run(alex, "market");
assertTrue(alex.heard("#7 Diamond x3 $99.50 by Mia"));
kit.panel.on("economy/market/buy", "{\"item_id\":\"DIAMOND\",\"amount\":3,\"new_balance\":10.0}");
run(alex, "market", "buy", "#7");
set.tick();
assertEquals(1, alex.inventory.size());
assertEquals(3, alex.inventory.get(0).count());
assertEquals(7, kit.panel.bodies.get(kit.panel.bodies.size() - 1).get("listing_id").getAsLong());
kit.panel.on("economy/market/list", "{\"id\":8}");
alex.held = new Item("IRON_INGOT", "Iron Ingot", 5, "x");
run(alex, "market", "sell", "12.5");
set.tick();
JsonObject listed = kit.panel.bodies.stream().filter(b -> b.has("seller_uuid")).findFirst().orElseThrow();
assertEquals("IRON_INGOT", listed.get("item_id").getAsString());
assertEquals(5, listed.get("amount").getAsInt());
assertEquals(12.5, listed.get("price").getAsDouble());
alex.denied.add("scopenet.command.market.sell");
alex.held = new Item("IRON_INGOT", "Iron Ingot", 5, "");
run(alex, "market", "sell", "1");
assertTrue(alex.last().contains("permission"));
assertNotNull(alex.held, "nothing taken without permission");
}
// ---- guilds --------------------------------------------------------------------------
private void inGuild(String role) {
kit.panel.on("guilds/player", "{\"in_guild\":true,\"guild\":{\"id\":\"g1\",\"name\":\"Iron\",\"tag\":\"IRON\",\"role\":\"" + role + "\",\"level\":2,\"claims_count\":3,\"max_claims\":16,"
+ "\"members\":[{\"name\":\"Alex\",\"role\":\"leader\"},{\"name\":\"Steve\",\"role\":\"member\"},{\"name\":\"Mia\",\"role\":\"member\"}]}}");
}
@Test void guildCreateClaimAndPermissions() {
kit.panel.on("guilds/create", "{}");
run(alex, "guild", "create", "Iron", "IRON");
assertTrue(alex.heard("Guild Iron [IRON] founded"));
assertTrue(kit.panel.claimsRefreshed);
run(alex, "guild", "create", "Only");
assertTrue(alex.last().startsWith("Usage"));
alex.pos = new Pos("minecraft:overworld", 35, 64, -20, 0, 0); // chunk 2, -2
kit.panel.on("guilds/claim", "{\"guild_name\":\"Iron\"}");
run(alex, "claim");
assertTrue(alex.heard("Chunk claimed successfully for Iron"));
JsonObject body = kit.panel.bodies.get(kit.panel.bodies.size() - 1);
assertEquals(2, body.get("chunk_x").getAsInt());
assertEquals(-2, body.get("chunk_z").getAsInt());
assertEquals("minecraft:overworld", body.get("dimension").getAsString());
alex.denied.add("scopenet.command.guild.leave");
run(alex, "guild", "leave");
assertTrue(alex.last().contains("permission to use /guild leave"));
alex.denied.add("scopenet.command.guild.sell.hand");
run(alex, "guild", "sell", "hand");
assertTrue(alex.last().contains("/guild sell hand"));
}
@Test void guildInfoMembersChatAndMap() {
inGuild("leader");
run(alex, "guild");
assertTrue(alex.heard("Guild: Iron [IRON]"));
assertTrue(alex.heard("Claimed chunks: 3 / 16"));
run(alex, "guild", "members");
assertTrue(alex.heard("Steve (member) [Online]"));
assertTrue(alex.heard("Mia (member) [Offline]"));
kit.platform.add("Bob");
run(alex, "guild", "chat", "hello", "team");
assertTrue(steve.heard("[Guild IRON] Alex: hello team"));
Kit.Player bob = kit.platform.online.values().stream().filter(p -> p.name.equals("Bob")).findFirst().orElseThrow();
assertFalse(bob.heard("hello team"), "non-members don't see guild chat");
kit.panel.claims.put("minecraft:overworld:1:0", new ChunkCheckResult(true, true, "Iron", "IRON"));
kit.panel.claims.put("minecraft:overworld:-1:0", new ChunkCheckResult(true, false, "Void", "VOID"));
run(alex, "guild", "map");
assertTrue(alex.heard("Territory Map (0, 0)"));
assertTrue(alex.inbox.stream().anyMatch(s -> s.contains("[P]") && s.contains("x") && s.contains("+")));
}
@Test void guildHomeNeedsOfficerAndPersists() {
inGuild("member");
run(steve, "guild", "sethome");
assertTrue(steve.last().contains("Only guild leaders and officers"));
inGuild("leader");
alex.pos = new Pos("minecraft:overworld", 100, 64, 100, 0, 0);
run(alex, "guild", "sethome");
assertTrue(alex.last().contains("Guild home waypoint set"));
CommandSet reopened = new CommandSet(kit.env, EssentialsConfig.defaults());
alex.pos = new Pos("minecraft:overworld", 0, 64, 0, 0, 0);
Kit.find(reopened.all(), "guild").run(alex, new String[]{"home"});
assertEquals(100, alex.pos.x());
}
@Test void guildBankBalanceDepositAndPay() {
kit.panel.on("guilds/bank", "{\"guild\":{\"tag\":\"IRON\",\"name\":\"Iron\"},\"role\":\"leader\",\"balance\":150.5,\"my_balance\":749.5,"
+ "\"recent\":[{\"kind\":\"deposit\",\"who\":\"Steve\",\"amount\":250.5,\"note\":\"\",\"at\":\"x\"},{\"kind\":\"withdraw\",\"who\":\"Alex\",\"amount\":100,\"note\":\"\",\"at\":\"x\"}]}");
run(alex, "guild", "bank");
assertTrue(alex.heard("[IRON] Iron Bank"));
assertTrue(alex.heard("Balance: $150.50"));
assertTrue(alex.heard("-$100.00 withdraw by Alex"));
kit.panel.on("guilds/bank/transfer", "{\"message\":\"Deposited $50.00 into [IRON] bank.\",\"balance\":200.5}");
run(alex, "guild", "bank", "deposit", "50");
set.tick();
assertTrue(alex.heard("Deposited $50.00 into [IRON] bank. Bank: $200.50"));
run(alex, "guild", "bank", "withdraw", "0.001");
assertTrue(alex.last().contains("at least 0.01"));
alex.denied.add("scopenet.command.guild.bank.withdraw");
run(alex, "guild", "bank", "withdraw", "5");
assertTrue(alex.last().contains("permission to withdraw"));
kit.panel.on("guilds/bank/pay", "{\"message\":\"Paid $10.00 to [VOID].\",\"balance\":190.5}");
run(alex, "guild", "pay", "VOID", "10");
set.tick();
assertTrue(kit.panel.calls.contains("guilds/bank/pay"));
assertEquals("VOID", kit.panel.bodies.get(kit.panel.bodies.size() - 1).get("to_tag").getAsString());
}
@Test void guildSellHandCreditsTheBank() {
kit.panel.on("guilds/bank/credit", "{\"message\":\"Sold to the shop: $70.00 into [IRON] bank.\",\"balance\":70.0}");
alex.held = new Item("DIAMOND", "Diamond", 2, "");
run(alex, "guild", "sell", "hand");
set.tick();
JsonObject body = kit.panel.bodies.get(0);
assertEquals(70.0, body.get("amount").getAsDouble());
assertFalse(body.has("delta"));
assertTrue(alex.heard("Bank: $70.00"));
}
}
@@ -23,6 +23,10 @@ class EssentialsServiceTest {
public void send(String m) { inbox.add(m.replaceAll("§.", "")); } public void send(String m) { inbox.add(m.replaceAll("§.", "")); }
public void teleport(Pos p) { pos = p; } public void teleport(Pos p) { pos = p; }
public boolean hasPermission(String n) { return true; } public boolean hasPermission(String n) { return true; }
public long playtimeSeconds() { return 0; }
public java.util.Optional<Item> heldItem() { return java.util.Optional.empty(); }
public void clearHeld() { }
public void give(Item i) { }
String last() { return inbox.get(inbox.size() - 1); } String last() { return inbox.get(inbox.size() - 1); }
} }
@@ -35,6 +39,9 @@ class EssentialsServiceTest {
public Optional<CorePlayer> playerByName(String n) { return online.values().stream().filter(p -> p.name.equalsIgnoreCase(n)).map(p -> (CorePlayer) p).findFirst(); } public Optional<CorePlayer> playerByName(String n) { return online.values().stream().filter(p -> p.name.equalsIgnoreCase(n)).map(p -> (CorePlayer) p).findFirst(); }
public Optional<Pos> safeSurface(String w, int x, int z) { return safe ? Optional.of(new Pos(w, x, 70, z, 0, 0)) : Optional.empty(); } public Optional<Pos> safeSurface(String w, int x, int z) { return safe ? Optional.of(new Pos(w, x, 70, z, 0, 0)) : Optional.empty(); }
public Pos worldSpawn(String w) { return new Pos(w, 0, 80, 0, 0, 0); } public Pos worldSpawn(String w) { return new Pos(w, 0, 80, 0, 0, 0); }
public java.util.Collection<? extends CorePlayer> online() { return online.values(); }
public void runMain(Runnable r) { r.run(); }
public void runAsync(Runnable r) { r.run(); }
}; };
Fake alex, steve; Fake alex, steve;
EssentialsService svc; EssentialsService svc;
@@ -0,0 +1,85 @@
package net.scopenet.core;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.scopenet.integration.ChunkCheckResult;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Logger;
/** Fakes for the shared-core tests: players, a platform that runs everything inline, and a scriptable panel. */
final class Kit {
static final class Player implements CorePlayer {
final UUID id = UUID.randomUUID();
final String name;
Pos pos = new Pos("minecraft:overworld", 0, 64, 0, 0, 0);
final List<String> inbox = new ArrayList<>();
final Set<String> denied = new HashSet<>();
Item held;
final List<Item> inventory = new ArrayList<>();
long playtime = 3700;
Player(String name) { this.name = name; }
public UUID uuid() { return id; }
public String name() { return name; }
public Pos pos() { return pos; }
public void send(String m) { inbox.add(Format.plain(m)); }
public void teleport(Pos p) { pos = p; }
public boolean hasPermission(String n) { return !denied.contains(n); }
public long playtimeSeconds() { return playtime; }
public Optional<Item> heldItem() { return Optional.ofNullable(held); }
public void clearHeld() { held = null; }
public void give(Item i) { inventory.add(i); }
String last() { return inbox.get(inbox.size() - 1); }
boolean heard(String text) { return inbox.stream().anyMatch(s -> s.contains(text)); }
}
static final class FakePlatform implements Platform {
final Map<UUID, Player> online = new LinkedHashMap<>();
boolean safe = true;
Player add(String name) { Player p = new Player(name); online.put(p.id, p); return p; }
public Optional<CorePlayer> player(UUID u) { return Optional.ofNullable(online.get(u)); }
public Optional<CorePlayer> playerByName(String n) { return online.values().stream().filter(p -> p.name.equalsIgnoreCase(n)).map(p -> (CorePlayer) p).findFirst(); }
public Collection<? extends CorePlayer> online() { return online.values(); }
public Optional<Pos> safeSurface(String w, int x, int z) { return safe ? Optional.of(new Pos(w, x, 70, z, 0, 0)) : Optional.empty(); }
public Pos worldSpawn(String w) { return new Pos(w, 0, 80, 0, 0, 0); }
public void runMain(Runnable r) { r.run(); }
public void runAsync(Runnable r) { r.run(); }
}
interface Handler { JsonElement handle(JsonObject body) throws Exception; }
static final class FakePanel implements Panel {
final Map<String, Handler> handlers = new HashMap<>();
final List<String> calls = new ArrayList<>();
final List<JsonObject> bodies = new ArrayList<>();
final Map<String, ChunkCheckResult> claims = new HashMap<>();
boolean claimsRefreshed;
public JsonElement call(String endpoint, JsonObject body) throws Exception {
calls.add(endpoint);
bodies.add(body);
Handler h = handlers.get(endpoint);
if (h == null) throw new net.scopenet.integration.PanelClient.HttpFailure(404, "no handler for " + endpoint);
return h.handle(body);
}
public void claimsChanged() { claimsRefreshed = true; }
public ChunkCheckResult check(String dim, int cx, int cz, UUID p) {
return claims.getOrDefault(dim + ":" + cx + ":" + cz, new ChunkCheckResult(false, true, null, null));
}
void on(String endpoint, String json) { handlers.put(endpoint, b -> com.google.gson.JsonParser.parseString(json)); }
}
final FakePlatform platform = new FakePlatform();
final FakePanel panel = new FakePanel();
final AtomicLong now = new AtomicLong(1_000);
final Env env;
Kit(Path dir) {
env = new Env(platform, panel, Features.all(), dir, now::get, new Random(1), Logger.getLogger("test"));
}
static CoreCommand find(List<CoreCommand> all, String name) {
return all.stream().filter(c -> c.name().equals(name)).findFirst().orElseThrow(() -> new AssertionError("no command " + name));
}
}