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

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

No files matched your search

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