diff --git a/integrations/common/src/main/java/net/scopenet/core/Cmd.java b/integrations/common/src/main/java/net/scopenet/core/Cmd.java new file mode 100644 index 0000000..b92930d --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Cmd.java @@ -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 aliases; + private final String permission; + private final BiConsumer run; + private final BiFunction> complete; + + Cmd(String name, List aliases, String permission, BiConsumer run, + BiFunction> complete) { + this.name = name; this.aliases = aliases; this.permission = permission; this.run = run; this.complete = complete; + } + + static Cmd of(String name, List aliases, BiConsumer run) { + return new Cmd(name, aliases, "scopenet.command." + name, run, (p, a) -> List.of()); + } + + Cmd completing(BiFunction> c) { return new Cmd(name, aliases, permission, run, c); } + + @Override public String name() { return name; } + @Override public List aliases() { return aliases; } + @Override public String permission() { return permission; } + @Override public void run(CorePlayer player, String[] args) { run.accept(player, args); } + @Override public List complete(CorePlayer player, String[] args) { return complete.apply(player, args); } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/CommandSet.java b/integrations/common/src/main/java/net/scopenet/core/CommandSet.java new file mode 100644 index 0000000..288d2cd --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/CommandSet.java @@ -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 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 list, String system, java.util.function.BooleanSupplier enabled) { + for (CoreCommand c : list) commands.add(new Gated(c, system, enabled)); + } + + public List 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 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 complete(CorePlayer p, String[] args) { return inner.complete(p, args); } + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/CoreCommand.java b/integrations/common/src/main/java/net/scopenet/core/CoreCommand.java new file mode 100644 index 0000000..1665479 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/CoreCommand.java @@ -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 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 complete(CorePlayer player, String[] args) { return List.of(); } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/CorePlayer.java b/integrations/common/src/main/java/net/scopenet/core/CorePlayer.java index 0266609..52f17a1 100644 --- a/integrations/common/src/main/java/net/scopenet/core/CorePlayer.java +++ b/integrations/common/src/main/java/net/scopenet/core/CorePlayer.java @@ -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 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); } diff --git a/integrations/common/src/main/java/net/scopenet/core/EconomyCommands.java b/integrations/common/src/main/java/net/scopenet/core/EconomyCommands.java new file mode 100644 index 0000000..397f545 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/EconomyCommands.java @@ -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 build() { + List 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 "); return; } + Optional 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 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 (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 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 "); 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 ."); 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 " + 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)); + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/Env.java b/integrations/common/src/main/java/net/scopenet/core/Env.java new file mode 100644 index 0000000..ed8e419 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Env.java @@ -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 void io(Callable work, Consumer ok, Consumer 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); } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/EssentialsCommands.java b/integrations/common/src/main/java/net/scopenet/core/EssentialsCommands.java new file mode 100644 index 0000000..309ad1f --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/EssentialsCommands.java @@ -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 build(Env env, EssentialsService ess) { + List 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 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 )"); + }).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 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 )"); + }).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 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 names(Env env, CorePlayer except) { + List names = new ArrayList<>(); + for (CorePlayer p : env.platform.online()) if (!p.uuid().equals(except.uuid())) names.add(p.name()); + return names; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java b/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java index 45d1cf6..55c1d89 100644 --- a/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java +++ b/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java @@ -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); diff --git a/integrations/common/src/main/java/net/scopenet/core/Features.java b/integrations/common/src/main/java/net/scopenet/core/Features.java new file mode 100644 index 0000000..fd7e045 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Features.java @@ -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, "$"); } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/Format.java b/integrations/common/src/main/java/net/scopenet/core/Format.java new file mode 100644 index 0000000..cf4e6fd --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Format.java @@ -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"; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/GuildCommands.java b/integrations/common/src/main/java/net/scopenet/core/GuildCommands.java new file mode 100644 index 0000000..0ed84c0 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/GuildCommands.java @@ -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 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 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 loaded = GSON.fromJson(in, new com.google.gson.reflect.TypeToken>() {}.getType()); + if (loaded != null) guildHomes.putAll(loaded); + } catch (IOException | RuntimeException e) { + throw new IllegalStateException("Could not read " + homesFile + ": " + e.getMessage(), e); + } + } + } + + List build() { + List 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 ", "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 ", "Send message to guild members"}, + {"/guild sethome / /guild home", "Guild waypoint base"}, {"/guild bank [deposit|withdraw ]", "Guild money"}, + {"/guild sell [hand]", "Sell items to the shop for the guild bank"}, {"/guild market sell | buy <#id>", "Trade on the market as a guild"}, + {"/guild pay ", "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 "); 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 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 "); + 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 "); 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 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 ]"); + 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 , /guild sell, /guild market, /guild pay "); + }, 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 "); 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); + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/Item.java b/integrations/common/src/main/java/net/scopenet/core/Item.java new file mode 100644 index 0000000..d3940c7 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Item.java @@ -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); + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/ItemValues.java b/integrations/common/src/main/java/net/scopenet/core/ItemValues.java new file mode 100644 index 0000000..812418f --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/ItemValues.java @@ -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 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 all() { return java.util.Collections.unmodifiableMap(VALUES); } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/Jobs.java b/integrations/common/src/main/java/net/scopenet/core/Jobs.java new file mode 100644 index 0000000..fd37678 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Jobs.java @@ -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 jobs = new LinkedHashMap<>(); + private final Set 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 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 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 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 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()); + } + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/Panel.java b/integrations/common/src/main/java/net/scopenet/core/Panel.java new file mode 100644 index 0000000..d7fd676 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Panel.java @@ -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/} 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); +} diff --git a/integrations/common/src/main/java/net/scopenet/core/PanelAdapter.java b/integrations/common/src/main/java/net/scopenet/core/PanelAdapter.java new file mode 100644 index 0000000..55dcfca --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/PanelAdapter.java @@ -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); + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/Platform.java b/integrations/common/src/main/java/net/scopenet/core/Platform.java index 52e91f6..7bbe6d1 100644 --- a/integrations/common/src/main/java/net/scopenet/core/Platform.java +++ b/integrations/common/src/main/java/net/scopenet/core/Platform.java @@ -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 player(UUID uuid); Optional playerByName(String name); + Collection 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 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); } diff --git a/integrations/common/src/test/java/net/scopenet/core/CommandsTest.java b/integrations/common/src/test/java/net/scopenet/core/CommandsTest.java new file mode 100644 index 0000000..7dc47f2 --- /dev/null +++ b/integrations/common/src/test/java/net/scopenet/core/CommandsTest.java @@ -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(); + 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 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")); + } +} diff --git a/integrations/common/src/test/java/net/scopenet/core/EssentialsServiceTest.java b/integrations/common/src/test/java/net/scopenet/core/EssentialsServiceTest.java index 251b958..2276ebd 100644 --- a/integrations/common/src/test/java/net/scopenet/core/EssentialsServiceTest.java +++ b/integrations/common/src/test/java/net/scopenet/core/EssentialsServiceTest.java @@ -23,6 +23,10 @@ class EssentialsServiceTest { public void send(String m) { inbox.add(m.replaceAll("§.", "")); } public void teleport(Pos p) { pos = p; } public boolean hasPermission(String n) { return true; } + public long playtimeSeconds() { return 0; } + public java.util.Optional heldItem() { return java.util.Optional.empty(); } + public void clearHeld() { } + public void give(Item i) { } String last() { return inbox.get(inbox.size() - 1); } } @@ -35,6 +39,9 @@ class EssentialsServiceTest { public Optional playerByName(String n) { return online.values().stream().filter(p -> p.name.equalsIgnoreCase(n)).map(p -> (CorePlayer) p).findFirst(); } public Optional 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 java.util.Collection online() { return online.values(); } + public void runMain(Runnable r) { r.run(); } + public void runAsync(Runnable r) { r.run(); } }; Fake alex, steve; EssentialsService svc; diff --git a/integrations/common/src/test/java/net/scopenet/core/Kit.java b/integrations/common/src/test/java/net/scopenet/core/Kit.java new file mode 100644 index 0000000..99059da --- /dev/null +++ b/integrations/common/src/test/java/net/scopenet/core/Kit.java @@ -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 inbox = new ArrayList<>(); + final Set denied = new HashSet<>(); + Item held; + final List 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 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 online = new LinkedHashMap<>(); + boolean safe = true; + Player add(String name) { Player p = new Player(name); online.put(p.id, p); return p; } + public Optional player(UUID u) { return Optional.ofNullable(online.get(u)); } + public Optional playerByName(String n) { return online.values().stream().filter(p -> p.name.equalsIgnoreCase(n)).map(p -> (CorePlayer) p).findFirst(); } + public Collection online() { return online.values(); } + public Optional 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 handlers = new HashMap<>(); + final List calls = new ArrayList<>(); + final List bodies = new ArrayList<>(); + final Map 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 all, String name) { + return all.stream().filter(c -> c.name().equals(name)).findFirst().orElseThrow(() -> new AssertionError("no command " + name)); + } +}