Shared command core: economy, guilds, bank, escrow jobs, tests
This commit is contained in:
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;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** What the shared logic needs from an online player. Each platform provides an adapter. */
|
||||
@@ -7,9 +8,17 @@ public interface CorePlayer {
|
||||
UUID uuid();
|
||||
String name();
|
||||
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 teleport(Pos destination);
|
||||
/** True if the player may use {@code node}. Platforms map this onto LuckPerms or their own default. */
|
||||
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.");
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
remember(p.uuid(), p.pos());
|
||||
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;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
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 {
|
||||
Optional<CorePlayer> player(UUID uuid);
|
||||
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.
|
||||
* Called on the server thread.
|
||||
*/
|
||||
Optional<Pos> safeSurface(String world, int x, int z);
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user