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;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user