diff --git a/integrations/common/src/main/java/net/scopenet/core/ActionPoller.java b/integrations/common/src/main/java/net/scopenet/core/ActionPoller.java new file mode 100644 index 0000000..b4d1321 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/ActionPoller.java @@ -0,0 +1,68 @@ +package net.scopenet.core; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.Optional; +import java.util.UUID; + +/** + * Collects what the panel queued for this server (a teleport request or message sent from the map, an invitation + * notice) every few seconds, and carries it out with the server's normal rules. + */ +public final class ActionPoller { + /** Teleport requests use each platform's own TPA, so they obey its rules and permissions. */ + public interface Tpa { void request(CorePlayer requester, CorePlayer target); } + + public static final long EVERY_MS = 3_000; + + private final Env env; + private final Tpa tpa; + private long next; + private volatile boolean running; + + public ActionPoller(Env env, Tpa tpa) { this.env = env; this.tpa = tpa; } + + /** Call often (every tick or second) on the server thread; it asks the panel at most every few seconds. */ + public void tick() { + long now = env.clock.getAsLong(); + if (running || now < next) return; + next = now + EVERY_MS; + running = true; + env.platform.runAsync(() -> { + try { + JsonElement answer = env.panel.call("actions/poll", new JsonObject()); + JsonArray actions = answer.isJsonObject() && answer.getAsJsonObject().has("actions") ? answer.getAsJsonObject().getAsJsonArray("actions") : new JsonArray(); + if (actions.size() > 0) env.platform.runMain(() -> actions.forEach(a -> apply(a.getAsJsonObject()))); + } catch (Exception e) { + // The panel is briefly unreachable: try again on the next round. + } finally { + running = false; + } + }); + } + + private static String text(JsonObject o, String k) { return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : ""; } + + private Optional player(String uuid) { + try { return env.platform.player(UUID.fromString(uuid)); } catch (IllegalArgumentException e) { return Optional.empty(); } + } + + void apply(JsonObject a) { + String kind = text(a, "kind"); + Optional to = player(text(a, "to_uuid")); + if (to.isEmpty()) return; // left the server since it was queued + switch (kind) { + case "notify" -> to.get().send(text(a, "text")); + case "message" -> to.get().send(Format.GRAY + "[" + Format.AQUA + "Map" + Format.GRAY + "] " + Format.YELLOW + text(a, "from_name") + Format.GRAY + ": " + Format.WHITE + text(a, "text")); + case "tpa" -> { + Optional from = player(text(a, "from_uuid")); + if (from.isEmpty()) { to.get().send(Format.GRAY + text(a, "from_name") + " asked to teleport to you, but has left."); return; } + if (!from.get().hasPermission("scopenet.command.tpa")) { from.get().send(Format.RED + "You do not have permission to send teleport requests."); return; } + tpa.request(from.get(), to.get()); + } + default -> { } + } + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/ClientLink.java b/integrations/common/src/main/java/net/scopenet/core/ClientLink.java index 4d66adb..cfa971f 100644 --- a/integrations/common/src/main/java/net/scopenet/core/ClientLink.java +++ b/integrations/common/src/main/java/net/scopenet/core/ClientLink.java @@ -189,6 +189,16 @@ public final class ClientLink { send(p, m); } + /** Ask the client mod to open one of its windows ("market" or "shop"). Returns false if the player has no client mod. */ + public boolean open(CorePlayer p, String screen) { + if (!clients.contains(p.uuid())) return false; + JsonObject m = new JsonObject(); + m.addProperty("t", "open"); + m.addProperty("screen", screen); + send(p, m); + return true; + } + /** A toast for the client (level-up, achievement, friend request…). */ public void notify(CorePlayer p, String kind, String title, String text) { if (!clients.contains(p.uuid())) return; diff --git a/integrations/common/src/main/java/net/scopenet/core/CommandSet.java b/integrations/common/src/main/java/net/scopenet/core/CommandSet.java index 288d2cd..078eb1d 100644 --- a/integrations/common/src/main/java/net/scopenet/core/CommandSet.java +++ b/integrations/common/src/main/java/net/scopenet/core/CommandSet.java @@ -8,14 +8,20 @@ import java.util.List; public final class CommandSet { public final EssentialsService essentials; public final Jobs jobs; + public final ShopPoints shops; + private final GuildCommands guildCommands; + private final EconomyCommands economyCommands; 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); + shops = new ShopPoints(dir.resolve("shops.json")); + EconomyCommands economy = new EconomyCommands(env, jobs, shops); GuildCommands guilds = new GuildCommands(env, jobs, economy, essentials, dir.resolve("guild-homes.json")); + economyCommands = economy; + guildCommands = guilds; 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()); @@ -27,6 +33,15 @@ public final class CommandSet { public List all() { return List.copyOf(commands); } + /** Run a command by name as this player, with the same checks as typing it. */ + public void run(String name, CorePlayer player, String... args) { + for (CoreCommand c : commands) if (c.name().equals(name)) { c.run(player, args); return; } + throw new IllegalArgumentException("no command " + name); + } + + /** Guild homes by guild id (for the map). */ + public java.util.Map guildHomes() { return guildCommands.guildHomes(); } + /** Call about once a second from the server thread. */ public void tick() { jobs.tick(); } diff --git a/integrations/common/src/main/java/net/scopenet/core/EconomyCommands.java b/integrations/common/src/main/java/net/scopenet/core/EconomyCommands.java index 397f545..478b9c8 100644 --- a/integrations/common/src/main/java/net/scopenet/core/EconomyCommands.java +++ b/integrations/common/src/main/java/net/scopenet/core/EconomyCommands.java @@ -14,8 +14,9 @@ import java.util.*; final class EconomyCommands { private final Env env; private final Jobs jobs; + private final ShopPoints shops; - EconomyCommands(Env env, Jobs jobs) { this.env = env; this.jobs = jobs; } + EconomyCommands(Env env, Jobs jobs, ShopPoints shops) { this.env = env; this.jobs = jobs; this.shops = shops; } List build() { List out = new ArrayList<>(); @@ -24,7 +25,7 @@ final class EconomyCommands { 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("market", List.of("ah", "auction"), (p, a) -> market(p, a, false)).completing((p, a) -> a.length <= 1 ? List.of("sell", "buy", "point") : a.length == 2 && a[0].equalsIgnoreCase("point") ? List.of("add", "remove", "list") : 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))); @@ -120,6 +121,12 @@ final class EconomyCommands { /** {@code asGuild}: list on behalf of the player's guild, or buy with its bank. */ void market(CorePlayer p, String[] args, boolean asGuild) { + if (!asGuild && args.length >= 1 && args[0].equalsIgnoreCase("point")) { + // The block under the player's feet: the platform-neutral stand-in for "the block you look at". + Pos at = p.pos(); + shops.command(p, Arrays.copyOfRange(args, 1, args.length), new Pos(at.world(), at.x(), at.y() - 1, at.z(), 0, 0)); + return; + } 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; 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 55c1d89..c2ac09a 100644 --- a/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java +++ b/integrations/common/src/main/java/net/scopenet/core/EssentialsService.java @@ -132,6 +132,16 @@ public final class EssentialsService { // ---- warps ------------------------------------------------------------- + /** Every warp, for the map. A copy. */ + public Map allWarps() { return new TreeMap<>(warps); } + + /** Every player's homes, for the map (only shown when the admin turns that on). A copy. */ + public Map> allHomes() { + Map> out = new HashMap<>(); + homes.forEach((id, h) -> out.put(id, new TreeMap<>(h))); + return out; + } + public List warpNames() { return new ArrayList<>(new TreeSet<>(warps.keySet())); } public Optional warpAt(String name) { return Optional.ofNullable(warps.get(name.toLowerCase(Locale.ROOT))); } diff --git a/integrations/common/src/main/java/net/scopenet/core/GuildCommands.java b/integrations/common/src/main/java/net/scopenet/core/GuildCommands.java index 0ed84c0..7f2bc5c 100644 --- a/integrations/common/src/main/java/net/scopenet/core/GuildCommands.java +++ b/integrations/common/src/main/java/net/scopenet/core/GuildCommands.java @@ -15,7 +15,7 @@ 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 static final Set GATED = Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay", "invite", "accept", "decline"); private final Env env; private final Jobs jobs; @@ -39,7 +39,8 @@ final class GuildCommands { 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())); + ? List.of("info", "create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay", "invite", "accept", "decline") + : a.length == 2 && a[0].equalsIgnoreCase("invite") ? EssentialsCommands.names(env, p) : 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; @@ -78,6 +79,9 @@ final class GuildCommands { case "members" -> members(p); case "bank" -> bank(p, rest); case "pay" -> pay(p, rest); + case "invite" -> invite(p, rest); + case "accept" -> answer(p, rest, true); + case "decline", "deny" -> answer(p, rest, false); case "sell" -> economy.sell(p, rest, true); case "market" -> economy.market(p, rest, true); default -> help(p); @@ -93,6 +97,7 @@ final class GuildCommands { {"/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"}, + {"/guild invite ", "Invite a player (leaders and officers)"}, {"/guild accept [tag] | decline [tag]", "Answer a guild invitation"}, }; for (String[] r : rows) p.send(Format.YELLOW + r[0] + Format.GRAY + " - " + r[1]); } @@ -254,6 +259,30 @@ final class GuildCommands { }); } + // ---- invitations ------------------------------------------------------------ + + private void invite(CorePlayer p, String[] args) { + if (args.length < 1) { p.send(Format.RED + "Usage: /guild invite "); return; } + JsonObject body = uuidBody(p); + body.addProperty("target", args[0]); + env.io(() -> env.panel.call("guilds/invite/send", body).getAsJsonObject(), + r -> p.send(Format.GREEN + "Invitation sent to " + Format.YELLOW + (r.has("player") ? r.get("player").getAsString() : args[0]) + Format.GREEN + "."), + e -> p.send(Format.RED + "Could not invite: " + e)); + } + + private void answer(CorePlayer p, String[] args, boolean accept) { + JsonObject body = uuidBody(p); + body.addProperty("tag", args.length > 0 ? args[0] : ""); + body.addProperty("accept", accept); + env.io(() -> { JsonObject r = env.panel.call("guilds/invite/respond", body).getAsJsonObject(); if (accept) env.panel.claimsChanged(); return r; }, + r -> p.send(accept ? Format.GREEN + "Welcome to " + Format.YELLOW + "[" + r.get("tag").getAsString() + "] " + r.get("guild").getAsString() + Format.GREEN + "!" + : Format.YELLOW + "Invitation declined."), + e -> p.send(Format.RED + e)); + } + + /** Guild homes by guild id, for the map. */ + Map guildHomes() { return Map.copyOf(guildHomes); } + // ---- bank --------------------------------------------------------------- private void bank(CorePlayer p, String[] args) { diff --git a/integrations/common/src/main/java/net/scopenet/core/PlayerCache.java b/integrations/common/src/main/java/net/scopenet/core/PlayerCache.java index f12e900..d230567 100644 --- a/integrations/common/src/main/java/net/scopenet/core/PlayerCache.java +++ b/integrations/common/src/main/java/net/scopenet/core/PlayerCache.java @@ -59,22 +59,22 @@ public final class PlayerCache { // ---- small accessors used by placeholders and the client link ----------------------------- - static JsonObject obj(JsonObject o, String key) { + public static JsonObject obj(JsonObject o, String key) { JsonElement e = o == null ? null : o.get(key); return e != null && e.isJsonObject() ? e.getAsJsonObject() : null; } - static double num(JsonObject o, String key, double fallback) { + public static double num(JsonObject o, String key, double fallback) { JsonElement e = o == null ? null : o.get(key); return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsDouble() : fallback; } - static String str(JsonObject o, String key, String fallback) { + public static String str(JsonObject o, String key, String fallback) { JsonElement e = o == null ? null : o.get(key); return e != null && e.isJsonPrimitive() ? e.getAsString() : fallback; } - static JsonArray arr(JsonObject o, String key) { + public static JsonArray arr(JsonObject o, String key) { JsonElement e = o == null ? null : o.get(key); return e != null && e.isJsonArray() ? e.getAsJsonArray() : new JsonArray(); } diff --git a/integrations/common/src/main/java/net/scopenet/core/ShopPoints.java b/integrations/common/src/main/java/net/scopenet/core/ShopPoints.java new file mode 100644 index 0000000..e8da7de --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/ShopPoints.java @@ -0,0 +1,99 @@ +package net.scopenet.core; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +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; + +/** + * Physical shops: a block in the world that opens the market or the server shop when a player right-clicks it, and + * shows up as a pin on the map. The GUI itself is the same as typing the command, so it works on Paper (chest window) + * and with the client mod (shop and market windows). Set up by staff with {@code /market point add|remove|list}. + */ +public final class ShopPoints { + public record Shop(String name, String kind, Pos pos) {} + + public static final String NODE = "scopenet.command.market.point"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private final Path file; + private final Map shops = new ConcurrentHashMap<>(); + + public ShopPoints(Path file) { + this.file = file; + if (Files.exists(file)) { + try (Reader in = Files.newBufferedReader(file)) { + List loaded = GSON.fromJson(in, new TypeToken>() {}.getType()); + if (loaded != null) loaded.forEach(s -> shops.put(s.name().toLowerCase(Locale.ROOT), s)); + } catch (IOException | RuntimeException e) { + throw new IllegalStateException("Could not read " + file + ": " + e.getMessage(), e); + } + } + } + + public Collection all() { return new ArrayList<>(new TreeMap<>(shops).values()); } + + /** The shop at this block, if any. */ + public Optional at(String dimension, int x, int y, int z) { + for (Shop s : shops.values()) { + Pos p = s.pos(); + if (p.world().equals(dimension) && p.blockX() == x && p.blockY() == y && p.blockZ() == z) return Optional.of(s); + } + return Optional.empty(); + } + + private void save() { + try { + if (file.getParent() != null) Files.createDirectories(file.getParent()); + Path tmp = file.resolveSibling(file.getFileName() + ".tmp"); + try (Writer w = Files.newBufferedWriter(tmp)) { GSON.toJson(all(), w); } + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new IllegalStateException("Could not save " + file + ": " + e.getMessage(), e); + } + } + + /** + * {@code /market point add [market|shop]} marks the block the player is looking at... or, where the platform can't + * tell, the block they stand on. {@code target} is that block. {@code remove } and {@code list} as expected. + */ + public void command(CorePlayer p, String[] args, Pos target) { + if (!p.hasPermission(NODE)) { p.send(Format.RED + "You do not have permission to manage shop points."); return; } + String sub = args.length == 0 ? "list" : args[0].toLowerCase(Locale.ROOT); + switch (sub) { + case "add" -> { + if (args.length < 2) { p.send(Format.RED + "Usage: /market point add [market|shop]"); return; } + String name = args[1]; + if (!name.matches("[A-Za-z0-9_-]{1,24}")) { p.send(Format.RED + "Names are 1-24 letters, digits, - or _."); return; } + String kind = args.length > 2 ? args[2].toLowerCase(Locale.ROOT) : "market"; + if (!kind.equals("market") && !kind.equals("shop")) { p.send(Format.RED + "The kind is market or shop."); return; } + if (at(target.world(), target.blockX(), target.blockY(), target.blockZ()).isPresent()) { p.send(Format.RED + "There is already a shop point on that block."); return; } + Pos block = new Pos(target.world(), target.blockX() + 0.5, target.blockY(), target.blockZ() + 0.5, 0, 0); + shops.put(name.toLowerCase(Locale.ROOT), new Shop(name, kind, block)); + save(); + p.send(Format.GREEN + "Shop point " + Format.YELLOW + name + Format.GREEN + " set (" + kind + "). Right-click that block to open the " + + (kind.equals("market") ? "market" : "shop") + "."); + } + case "remove", "delete" -> { + if (args.length < 2) { p.send(Format.RED + "Usage: /market point remove "); return; } + if (shops.remove(args[1].toLowerCase(Locale.ROOT)) == null) { p.send(Format.RED + "No shop point called '" + args[1] + "'."); return; } + save(); + p.send(Format.GREEN + "Shop point " + Format.YELLOW + args[1] + Format.GREEN + " removed."); + } + case "list" -> { + p.send(Format.GOLD + "=== Shop points ==="); + if (shops.isEmpty()) p.send(Format.GRAY + "None yet. Look at a block and use /market point add ."); + for (Shop s : all()) p.send(Format.YELLOW + s.name() + Format.GRAY + " (" + s.kind() + ") " + s.pos().blockX() + ", " + s.pos().blockY() + ", " + s.pos().blockZ()); + } + default -> p.send(Format.RED + "Usage: /market point add [market|shop] | remove | list"); + } + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/map/ChunkRegions.java b/integrations/common/src/main/java/net/scopenet/core/map/ChunkRegions.java new file mode 100644 index 0000000..46916b8 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/map/ChunkRegions.java @@ -0,0 +1,172 @@ +package net.scopenet.core.map; + +import java.util.*; + +/** + * Turns a set of claimed chunks into clean outlines for a map: one outer ring per connected area, plus a ring for + * every hole inside it. Coordinates are block coordinates (chunk x 16). Pure and fast: each chunk adds at most four edges. + */ +public final class ChunkRegions { + private ChunkRegions() {} + + public record Point(double x, double z) {} + + /** A closed loop; the last point connects back to the first. */ + public record Ring(List points) { + public double area() { + double sum = 0; + for (int i = 0; i < points.size(); i++) { + Point a = points.get(i), b = points.get((i + 1) % points.size()); + sum += a.x() * b.z() - b.x() * a.z(); + } + return sum / 2; + } + + public boolean contains(double x, double z) { + boolean inside = false; + for (int i = 0, j = points.size() - 1; i < points.size(); j = i++) { + Point a = points.get(i), b = points.get(j); + if ((a.z() > z) != (b.z() > z) && x < (b.x() - a.x()) * (z - a.z()) / (b.z() - a.z()) + a.x()) inside = !inside; + } + return inside; + } + } + + /** One connected area: its outline, the holes in it, how many chunks it has and a good spot for its label. */ + public record Region(Ring outer, List holes, int chunks, double labelX, double labelZ) {} + + public static long key(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); } + static int kx(long key) { return (int) (key >> 32); } + static int kz(long key) { return (int) key; } + + /** Split into areas that touch along an edge. */ + static List> components(Set chunks) { + List> out = new ArrayList<>(); + Set seen = new HashSet<>(); + List sorted = new ArrayList<>(chunks); + Collections.sort(sorted); + for (long start : sorted) { + if (!seen.add(start)) continue; + Set group = new HashSet<>(); + Deque queue = new ArrayDeque<>(); + queue.add(start); + group.add(start); + while (!queue.isEmpty()) { + long c = queue.poll(); + int x = kx(c), z = kz(c); + for (long n : new long[]{key(x + 1, z), key(x - 1, z), key(x, z + 1), key(x, z - 1)}) { + if (chunks.contains(n) && seen.add(n)) { group.add(n); queue.add(n); } + } + } + out.add(group); + } + return out; + } + + public static List regions(Set chunks) { + List out = new ArrayList<>(); + for (Set group : components(chunks)) { + List outers = new ArrayList<>(), holes = new ArrayList<>(); + for (Ring r : outline(group)) (r.area() > 0 ? outers : holes).add(r); + double[] label = labelSpot(group); + for (Ring o : outers) { + List mine = new ArrayList<>(); + for (Ring h : holes) if (outers.size() == 1 || o.contains(average(h, true), average(h, false))) mine.add(h); + out.add(new Region(o, mine, group.size(), label[0], label[1])); + } + } + out.sort(Comparator.comparingInt(Region::chunks).reversed().thenComparingDouble(r -> r.labelX()).thenComparingDouble(r -> r.labelZ())); + return out; + } + + private static double average(Ring ring, boolean x) { + double sum = 0; + for (Point p : ring.points()) sum += x ? p.x() : p.z(); + return sum / ring.points().size(); + } + + /** The middle of the chunk nearest to the area's average, so a label never lands outside a bent shape. */ + static double[] labelSpot(Set group) { + double mx = 0, mz = 0; + for (long c : group) { mx += kx(c) + 0.5; mz += kz(c) + 0.5; } + mx /= group.size(); + mz /= group.size(); + long best = group.iterator().next(); + double bestD = Double.MAX_VALUE; + for (long c : group) { + double d = Math.pow(kx(c) + 0.5 - mx, 2) + Math.pow(kz(c) + 0.5 - mz, 2); + if (d < bestD || (d == bestD && c < best)) { bestD = d; best = c; } + } + return new double[]{(kx(best) + 0.5) * 16, (kz(best) + 0.5) * 16}; + } + + private record Edge(int fx, int fz, int tx, int tz) {} + + /** Boundary loops of one connected area. Outer loops have positive area, holes negative. */ + static List outline(Set group) { + // Directed edges with the chunk on the right-hand side (clockwise around solid land). + Map> from = new HashMap<>(); + for (long c : group) { + int x = kx(c), z = kz(c); + if (!group.contains(key(x, z - 1))) add(from, new Edge(x, z, x + 1, z)); + if (!group.contains(key(x + 1, z))) add(from, new Edge(x + 1, z, x + 1, z + 1)); + if (!group.contains(key(x, z + 1))) add(from, new Edge(x + 1, z + 1, x, z + 1)); + if (!group.contains(key(x - 1, z))) add(from, new Edge(x, z + 1, x, z)); + } + List rings = new ArrayList<>(); + while (!from.isEmpty()) { + // Start from a stable edge so results don't depend on hash order. + long startKey = Collections.min(from.keySet()); + Edge first = from.get(startKey).get(0); + List loop = new ArrayList<>(); + Edge e = first; + do { + remove(from, e); + loop.add(new int[]{e.fx(), e.fz()}); + e = next(from, e); + if (e == null) break; + } while (!(e.fx() == first.fx() && e.fz() == first.fz())); + rings.add(new Ring(simplify(loop))); + } + return rings; + } + + private static void add(Map> m, Edge e) { m.computeIfAbsent(key(e.fx(), e.fz()), k -> new ArrayList<>()).add(e); } + + private static void remove(Map> m, Edge e) { + List list = m.get(key(e.fx(), e.fz())); + list.remove(e); + if (list.isEmpty()) m.remove(key(e.fx(), e.fz())); + } + + /** The edge leaving this edge's end. Where two loops touch at a corner, turn right to keep them apart. */ + private static Edge next(Map> m, Edge e) { + List options = m.get(key(e.tx(), e.tz())); + if (options == null || options.isEmpty()) return null; + if (options.size() == 1) return options.get(0); + int dx = e.tx() - e.fx(), dz = e.tz() - e.fz(); + Edge best = options.get(0); + int bestScore = Integer.MIN_VALUE; + for (Edge o : options) { + int ox = o.tx() - o.fx(), oz = o.tz() - o.fz(); + int cross = dx * oz - dz * ox; // >0: turns right in (x right, z down) coordinates + int score = cross > 0 ? 2 : cross == 0 ? 1 : 0; + if (score > bestScore) { bestScore = score; best = o; } + } + return best; + } + + /** Drop corner points that sit in a straight line, then scale chunks to blocks. */ + private static List simplify(List loop) { + List kept = new ArrayList<>(); + int n = loop.size(); + for (int i = 0; i < n; i++) { + int[] prev = loop.get((i + n - 1) % n), cur = loop.get(i), nxt = loop.get((i + 1) % n); + int cross = (cur[0] - prev[0]) * (nxt[1] - cur[1]) - (cur[1] - prev[1]) * (nxt[0] - cur[0]); + if (cross != 0) kept.add(cur); + } + List out = new ArrayList<>(); + for (int[] p : kept) out.add(new Point(p[0] * 16.0, p[1] * 16.0)); + return out; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/map/MapData.java b/integrations/common/src/main/java/net/scopenet/core/map/MapData.java new file mode 100644 index 0000000..dc3175d --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/map/MapData.java @@ -0,0 +1,18 @@ +package net.scopenet.core.map; + +import java.util.List; + +/** What the map shows, independent of any map software. Built by {@link MapModel}. */ +public record MapData(List claims, List pins, List players) { + public static MapData empty() { return new MapData(List.of(), List.of(), List.of()); } + + /** One connected area of a guild's land. {@code color} is 0xRRGGBB. */ + public record ClaimRegion(String id, String guildId, String name, String tag, String icon, int color, String dimension, + ChunkRegions.Ring outer, List holes, int chunks, double labelX, double labelZ, String detail) {} + + /** kind: spawn, warp, home, guild_home, market or shop. {@code detail} is HTML that is already escaped. */ + public record Pin(String id, String kind, String label, String dimension, double x, double y, double z, String detail) {} + + public record OnlinePlayer(String uuid, String name, long level, String title, long serverLevel, String guild, String guildTag, + String guildRole, String rank, long playtimeSecs) {} +} diff --git a/integrations/common/src/main/java/net/scopenet/core/map/MapModel.java b/integrations/common/src/main/java/net/scopenet/core/map/MapModel.java new file mode 100644 index 0000000..2c72a29 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/map/MapModel.java @@ -0,0 +1,145 @@ +package net.scopenet.core.map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.scopenet.core.Pos; +import net.scopenet.core.ShopPoints; +import net.scopenet.integration.ClaimIndex; + +import java.util.*; + +/** Builds {@link MapData} from what the server already knows: claims, warps, spawn, shop points, listings and players. */ +public final class MapModel { + private MapModel() {} + + /** Everything the map is built from. All of it must be safe to read from another thread. */ + public record Sources(Map> claims, Map warps, Collection spawns, + Map> homes, Map guildHomes, Collection shops, + JsonArray market, List players, String panelBase, boolean showHomes, + Map homeOwners) {} + + public record NamedPos(String label, Pos pos) {} + + public static String escape(String text) { + if (text == null) return ""; + StringBuilder out = new StringBuilder(text.length() + 8); + for (char c : text.toCharArray()) { + switch (c) { + case '&' -> out.append("&"); + case '<' -> out.append("<"); + case '>' -> out.append(">"); + case '"' -> out.append("""); + case '\'' -> out.append("'"); + default -> { if (c >= 32 || c == '\n') out.append(c); } + } + } + return out.toString(); + } + + /** A stable, pleasant colour per guild: hue from the id, fixed saturation and lightness. */ + public static int guildColor(String guildId) { + int h = guildId.hashCode(); + double hue = Math.floorMod(h, 360); + return hsl(hue, 0.65, 0.55); + } + + static int hsl(double h, double s, double l) { + double c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = l - c / 2; + double r, g, b; + if (h < 60) { r = c; g = x; b = 0; } else if (h < 120) { r = x; g = c; b = 0; } else if (h < 180) { r = 0; g = c; b = x; } + else if (h < 240) { r = 0; g = x; b = c; } else if (h < 300) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; } + return ((int) Math.round((r + m) * 255) << 16) | ((int) Math.round((g + m) * 255) << 8) | (int) Math.round((b + m) * 255); + } + + /** The icon address: a full web address as is, a path on the panel made absolute, empty if none. */ + public static String iconAddress(String icon, String panelBase) { + if (icon == null || icon.isBlank()) return ""; + String i = icon.trim(); + if (i.startsWith("https://") || i.startsWith("http://")) return i; + if (i.startsWith("/") && panelBase != null && !panelBase.isBlank()) return panelBase.replaceAll("/+$", "") + i; + return ""; + } + + public static MapData build(Sources s) { + List claims = new ArrayList<>(); + s.claims().forEach((dimension, guilds) -> { + for (ClaimIndex.GuildClaims g : guilds) { + int n = 0; + for (ChunkRegions.Region r : ChunkRegions.regions(g.chunks())) { + String detail = "
" + escape(g.name()) + " [" + escape(g.tag()) + "]
" + + r.chunks() + " claimed chunk" + (r.chunks() == 1 ? "" : "s") + " in this area
"; + claims.add(new MapData.ClaimRegion(g.id() + ":" + dimension + ":" + n++, g.id(), g.name(), g.tag(), iconAddress(g.icon(), s.panelBase()), + guildColor(g.id()), dimension, r.outer(), r.holes(), r.chunks(), r.labelX(), r.labelZ(), detail)); + } + } + }); + + List pins = new ArrayList<>(); + int i = 0; + for (Pos spawn : s.spawns()) pins.add(new MapData.Pin("spawn-" + i++, "spawn", "Spawn", spawn.world(), spawn.x(), spawn.y(), spawn.z(), + "
World spawn
")); + s.warps().forEach((name, p) -> pins.add(new MapData.Pin("warp-" + name, "warp", "Warp: " + name, p.world(), p.x(), p.y(), p.z(), + "
Warp " + escape(name) + "
/warp " + escape(name) + "
"))); + s.guildHomes().forEach((guildId, home) -> pins.add(new MapData.Pin("guild-home-" + guildId, "guild_home", home.label(), home.pos().world(), + home.pos().x(), home.pos().y(), home.pos().z(), "
" + escape(home.label()) + "
Guild home
"))); + if (s.showHomes()) { + s.homes().forEach((owner, byName) -> byName.forEach((name, p) -> { + String who = s.homeOwners().getOrDefault(owner, "a player"); + pins.add(new MapData.Pin("home-" + owner + "-" + name, "home", who + "'s home", p.world(), p.x(), p.y(), p.z(), + "
" + escape(who) + "
Home: " + escape(name) + "
")); + })); + } + String marketDetail = marketSummary(s.market()); + for (ShopPoints.Shop shop : s.shops()) { + boolean market = shop.kind().equals("market"); + String detail = "
" + escape(shop.name()) + "
" + + (market ? "Player market. Right-click to browse and trade." + marketDetail : "Server shop. Right-click to buy and sell.") + "
"; + Pos p = shop.pos(); + pins.add(new MapData.Pin("shop-" + shop.name(), market ? "market" : "shop", shop.name(), p.world(), p.x(), p.y(), p.z(), detail)); + } + pins.sort(Comparator.comparing(MapData.Pin::id)); + return new MapData(claims, pins, s.players()); + } + + /** "12 listings" and the newest few, as HTML. The market is per server, so every market point shows the same. */ + static String marketSummary(JsonArray listings) { + if (listings == null || listings.size() == 0) return "
No listings right now."; + StringBuilder b = new StringBuilder("
" + listings.size() + " listing" + (listings.size() == 1 ? "" : "s") + "
    "); + int shown = 0; + for (JsonElement e : listings) { + if (shown++ >= 5) break; + JsonObject l = e.getAsJsonObject(); + String guild = l.has("seller_guild") && l.get("seller_guild").isJsonPrimitive() ? "[" + l.get("seller_guild").getAsString() + "] guild" : text(l, "seller_name"); + b.append("
  • ").append(escape(text(l, "item_name"))).append(" x").append(l.has("amount") ? l.get("amount").getAsInt() : 1) + .append(" · ").append(String.format(Locale.US, "%,.2f", l.has("price") ? l.get("price").getAsDouble() : 0)) + .append(" by ").append(escape(guild)).append("
  • "); + } + return b.append("
").toString(); + } + + private static String text(JsonObject o, String key) { return o.has(key) && o.get(key).isJsonPrimitive() ? o.get(key).getAsString() : ""; } + + /** The file the map page reads to describe players who are online (public facts only: never balances). */ + public static JsonObject playersJson(String server, List players, long now) { + JsonObject root = new JsonObject(); + root.addProperty("updated", now); + root.addProperty("server", server); + JsonObject byId = new JsonObject(); + for (MapData.OnlinePlayer p : players) { + JsonObject o = new JsonObject(); + o.addProperty("name", p.name()); + o.addProperty("level", p.level()); + o.addProperty("title", p.title()); + o.addProperty("serverLevel", p.serverLevel()); + o.addProperty("guild", p.guild()); + o.addProperty("guildTag", p.guildTag()); + o.addProperty("guildRole", p.guildRole()); + o.addProperty("rank", p.rank()); + o.addProperty("playtime", p.playtimeSecs()); + byId.add(p.uuid(), o); + } + root.add("players", byId); + return root; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/map/MapService.java b/integrations/common/src/main/java/net/scopenet/core/map/MapService.java new file mode 100644 index 0000000..f7b362c --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/map/MapService.java @@ -0,0 +1,78 @@ +package net.scopenet.core.map; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.scopenet.core.*; +import net.scopenet.integration.ClaimIndex; + +import java.util.*; + +/** + * Gathers everything the map needs, from any thread. The map software (BlueMap) asks for a {@link MapData} every + * few seconds; the market listings are refreshed here on a slower clock so the map never waits on the panel. + */ +public final class MapService { + public static final long MARKET_REFRESH_MS = 60_000; + + private final Env env; + private final ClaimIndex claims; + private final CommandSet commands; + private final PlayerCache cache; + private final String panelBase; + private final boolean showHomes; + private final String serverName; + private volatile JsonArray market = new JsonArray(); + private volatile long marketAt = Long.MIN_VALUE / 2; + private volatile boolean fetching; + + public MapService(Env env, ClaimIndex claims, CommandSet commands, PlayerCache cache, String panelBase, boolean showHomes, String serverName) { + this.env = env; this.claims = claims; this.commands = commands; this.cache = cache; this.panelBase = panelBase; this.showHomes = showHomes; this.serverName = serverName; + } + + public String serverName() { return serverName; } + + /** Server thread, about once a second. */ + public void tick() { + long now = env.clock.getAsLong(); + if (fetching || now - marketAt < MARKET_REFRESH_MS || !env.features.economy()) return; + fetching = true; + marketAt = now; + env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> { + market = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray(); + fetching = false; + }, e -> fetching = false); + } + + /** Safe from any thread: reads only copies and concurrent collections. */ + public MapData snapshot() { + List players = onlinePlayers(); + Map names = new HashMap<>(); + for (CorePlayer p : env.platform.online()) names.put(p.uuid(), p.name()); + Map guildHomes = new TreeMap<>(); + Map labels = new HashMap<>(); + claims.byDimension().values().forEach(list -> list.forEach(g -> labels.put(g.id(), "[" + g.tag() + "] " + g.name()))); + commands.guildHomes().forEach((id, pos) -> guildHomes.put(id, new MapModel.NamedPos(labels.getOrDefault(id, "Guild home"), pos))); + Pos spawn = env.platform.worldSpawn("minecraft:overworld"); + MapModel.Sources sources = new MapModel.Sources( + env.features.guilds() && env.features.landClaiming() ? claims.byDimension() : Map.of(), + env.features.essentials() ? commands.essentials.allWarps() : Map.of(), + List.of(spawn), showHomes ? commands.essentials.allHomes() : Map.of(), guildHomes, commands.shops.all(), + market, players, panelBase, showHomes, names); + return MapModel.build(sources); + } + + private List onlinePlayers() { + List out = new ArrayList<>(); + for (CorePlayer p : env.platform.online()) { + JsonObject i = cache.info(p.uuid()); + JsonObject global = PlayerCache.obj(i, "global"), server = PlayerCache.obj(i, "server"), guild = PlayerCache.obj(i, "guild"), rank = PlayerCache.obj(i, "rank"); + out.add(new MapData.OnlinePlayer(p.uuid().toString(), p.name(), (long) PlayerCache.num(global, "level", 1), PlayerCache.str(global, "title", ""), + (long) PlayerCache.num(server, "level", 1), PlayerCache.str(guild, "name", ""), PlayerCache.str(guild, "tag", ""), PlayerCache.str(guild, "role", ""), + PlayerCache.str(rank, "display", PlayerCache.str(rank, "group", "")), (long) PlayerCache.num(i, "playtime_secs", 0))); + } + return out; + } + + public JsonObject playersJson() { return MapModel.playersJson(serverName, onlinePlayers(), System.currentTimeMillis()); } +} diff --git a/integrations/common/src/main/java/net/scopenet/integration/ClaimIndex.java b/integrations/common/src/main/java/net/scopenet/integration/ClaimIndex.java index 8b4ee1e..e67b176 100644 --- a/integrations/common/src/main/java/net/scopenet/integration/ClaimIndex.java +++ b/integrations/common/src/main/java/net/scopenet/integration/ClaimIndex.java @@ -16,7 +16,7 @@ import java.util.UUID; * The panel is polled for a new copy only when its revision changes. */ public final class ClaimIndex { - private record Guild(String id, String name, String tag) {} + private record Guild(String id, String name, String tag, String icon) {} private record Snapshot(String revision, Map> claims, Map> members, int total) {} @@ -59,6 +59,26 @@ public final class ClaimIndex { return new ChunkCheckResult(true, members != null && members.contains(player), guild.name(), guild.tag()); } + /** One guild's claimed chunks in one dimension, for drawing on a map. */ + public record GuildClaims(String id, String name, String tag, String icon, Set chunks) {} + + public static int chunkX(long key) { return (int) (key >> 32); } + public static int chunkZ(long key) { return (int) key; } + + /** Every claim grouped by dimension and guild (a copy; safe to use from any thread). */ + public Map> byDimension() { + Snapshot s = snapshot; + Map> out = new HashMap<>(); + if (s == null) return out; + s.claims().forEach((dimension, chunks) -> { + Map perGuild = new java.util.LinkedHashMap<>(); + chunks.forEach((key, guild) -> perGuild.computeIfAbsent(guild.id(), + id -> new GuildClaims(guild.id(), guild.name(), guild.tag(), guild.icon(), new HashSet<>())).chunks().add(key)); + out.put(dimension, new java.util.ArrayList<>(perGuild.values())); + }); + return out; + } + /** Applies a {@code guilds/claim-index} response. Returns true when the contents changed. */ public boolean apply(JsonObject response) { if (response.has("unchanged") && response.get("unchanged").getAsBoolean()) return false; @@ -67,7 +87,8 @@ public final class ClaimIndex { Guild[] guilds = new Guild[guildList.size()]; for (int i = 0; i < guilds.length; i++) { JsonObject g = guildList.get(i).getAsJsonObject(); - guilds[i] = new Guild(g.get("id").getAsString(), g.get("name").getAsString(), g.get("tag").getAsString()); + guilds[i] = new Guild(g.get("id").getAsString(), g.get("name").getAsString(), g.get("tag").getAsString(), + g.has("icon_url") && g.get("icon_url").isJsonPrimitive() ? g.get("icon_url").getAsString() : ""); } Map> claims = new HashMap<>(); JsonArray claimList = response.getAsJsonArray("claims"); diff --git a/integrations/common/src/test/java/net/scopenet/core/MapTest.java b/integrations/common/src/test/java/net/scopenet/core/MapTest.java new file mode 100644 index 0000000..c362786 --- /dev/null +++ b/integrations/common/src/test/java/net/scopenet/core/MapTest.java @@ -0,0 +1,321 @@ +package net.scopenet.core; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.scopenet.core.map.*; +import net.scopenet.integration.ClaimIndex; +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.*; + +import static org.junit.jupiter.api.Assertions.*; + +class MapTest { + @TempDir Path dir; + + static Set chunks(String... rows) { + Set set = new HashSet<>(); + for (int z = 0; z < rows.length; z++) for (int x = 0; x < rows[z].length(); x++) if (rows[z].charAt(x) == '#') set.add(ChunkRegions.key(x, z)); + return set; + } + + // ---- outlines -------------------------------------------------------------------- + + @Test void singleChunkIsASixteenBlockSquare() { + List r = ChunkRegions.regions(chunks("#")); + assertEquals(1, r.size()); + assertEquals(4, r.get(0).outer().points().size()); + assertEquals(256, r.get(0).outer().area(), 0.001); + assertEquals(8, r.get(0).labelX()); + assertEquals(8, r.get(0).labelZ()); + } + + @Test void straightEdgesAreMergedIntoOneSide() { + List r = ChunkRegions.regions(chunks("####", "####")); + assertEquals(4, r.get(0).outer().points().size(), "a 4x2 block is still just a rectangle"); + assertEquals(8 * 256, r.get(0).outer().area(), 0.001); + } + + @Test void lShapeHasSixCorners() { + List r = ChunkRegions.regions(chunks("#.", "#.", "##")); + assertEquals(1, r.size()); + assertEquals(6, r.get(0).outer().points().size()); + assertEquals(4 * 256, r.get(0).outer().area(), 0.001); + assertEquals(4, r.get(0).chunks()); + } + + @Test void aDonutKeepsItsHole() { + List r = ChunkRegions.regions(chunks("###", "#.#", "###")); + assertEquals(1, r.size()); + assertEquals(9 * 256, r.get(0).outer().area(), 0.001); + assertEquals(1, r.get(0).holes().size()); + assertEquals(-256, r.get(0).holes().get(0).area(), 0.001); + assertEquals(8, r.get(0).chunks()); + assertTrue(r.get(0).holes().get(0).contains(24, 24), "the hole is the middle chunk"); + } + + @Test void separateAreasAreSeparateRegionsAndDiagonalsDoNotJoin() { + List apart = ChunkRegions.regions(chunks("#.....#")); + assertEquals(2, apart.size()); + List diagonal = ChunkRegions.regions(chunks("#.", ".#")); + assertEquals(2, diagonal.size(), "chunks that only touch at a corner are two areas"); + for (ChunkRegions.Region region : diagonal) assertEquals(256, region.outer().area(), 0.001); + } + + @Test void theLabelSitsInsideABentShape() { + // A "U": the average is in the gap, but the label must be on land. + Set u = chunks("#.#", "#.#", "###"); + ChunkRegions.Region r = ChunkRegions.regions(u).get(0); + int cx = (int) Math.floor(r.labelX() / 16), cz = (int) Math.floor(r.labelZ() / 16); + assertTrue(u.contains(ChunkRegions.key(cx, cz))); + } + + @Test void negativeCoordinatesWork() { + Set set = new HashSet<>(List.of(ChunkRegions.key(-3, -2), ChunkRegions.key(-2, -2), ChunkRegions.key(-3, -1))); + ChunkRegions.Region r = ChunkRegions.regions(set).get(0); + assertEquals(3 * 256, r.outer().area(), 0.001); + assertEquals(-48, r.outer().points().stream().mapToDouble(ChunkRegions.Point::x).min().getAsDouble()); + assertEquals(-32, r.outer().points().stream().mapToDouble(ChunkRegions.Point::z).min().getAsDouble()); + } + + @Test void aBigRandomBlobStaysConsistent() { + Random rng = new Random(7); + Set blob = new HashSet<>(); + for (int i = 0; i < 400; i++) blob.add(ChunkRegions.key(rng.nextInt(20), rng.nextInt(20))); + double land = 0; + for (ChunkRegions.Region r : ChunkRegions.regions(blob)) { + double holes = r.holes().stream().mapToDouble(h -> -h.area()).sum(); + land += r.outer().area() - holes; + } + // Area inside all outlines (minus holes) is exactly the claimed land. + assertEquals(blob.size() * 256.0, land, 0.01); + } + + // ---- the model ----------------------------------------------------------------------- + + static ClaimIndex.GuildClaims guild(String id, String name, String tag, String icon, Set c) { return new ClaimIndex.GuildClaims(id, name, tag, icon, c); } + + static MapModel.Sources sources(Map> claims, Collection shops, JsonArray market, boolean homes) { + Pos spawn = new Pos("minecraft:overworld", 0, 80, 0, 0, 0); + UUID owner = UUID.randomUUID(); + return new MapModel.Sources(claims, Map.of("arena", new Pos("minecraft:overworld", 100, 65, -40, 0, 0)), List.of(spawn), + Map.of(owner, Map.of("base", new Pos("minecraft:overworld", 5, 70, 5, 0, 0))), Map.of("g1", new MapModel.NamedPos("[IRON] Iron Wolves", new Pos("minecraft:overworld", 32, 70, 32, 0, 0))), + shops, market, List.of(), "https://panel.example.com", homes, Map.of(owner, "Alex")); + } + + @Test void claimsBecomeColouredRegionsWithNamesAndIcons() { + Map> claims = Map.of("minecraft:overworld", List.of( + guild("g1", "Iron ", "IRON", "/textures/abc", chunks("##", "#.")), + guild("g2", "Void", "VOID", "https://cdn.example.com/v.png", chunks("#")))); + MapData data = MapModel.build(sources(claims, List.of(), new JsonArray(), false)); + assertEquals(2, data.claims().size()); + MapData.ClaimRegion iron = data.claims().stream().filter(c -> c.tag().equals("IRON")).findFirst().orElseThrow(); + assertEquals("https://panel.example.com/textures/abc", iron.icon(), "panel paths become full addresses"); + assertEquals("https://cdn.example.com/v.png", data.claims().stream().filter(c -> c.tag().equals("VOID")).findFirst().orElseThrow().icon()); + assertFalse(iron.detail().contains(""), "names are HTML-escaped: " + iron.detail()); + assertTrue(iron.detail().contains("<Wolves>")); + assertEquals(MapModel.guildColor("g1"), iron.color()); + assertNotEquals(MapModel.guildColor("g1"), MapModel.guildColor("g2")); + assertTrue(iron.color() >= 0 && iron.color() <= 0xFFFFFF); + assertEquals("", MapModel.iconAddress("javascript:alert(1)", "https://p"), "only web addresses and panel paths are used as icons"); + assertEquals("", MapModel.iconAddress(null, "https://p")); + } + + @Test void pinsCoverSpawnWarpsGuildHomesAndShopsButHomesAreOptIn() { + List shops = List.of(new ShopPoints.Shop("Bazaar", "market", new Pos("minecraft:overworld", 10, 64, 10, 0, 0)), + new ShopPoints.Shop("Smith", "shop", new Pos("minecraft:overworld", 20, 64, 10, 0, 0))); + MapData off = MapModel.build(sources(Map.of(), shops, new JsonArray(), false)); + Set kinds = new TreeSet<>(); + off.pins().forEach(p -> kinds.add(p.kind())); + assertEquals(new TreeSet<>(List.of("guild_home", "market", "shop", "spawn", "warp")), kinds, "homes stay private by default"); + MapData on = MapModel.build(sources(Map.of(), shops, new JsonArray(), true)); + MapData.Pin home = on.pins().stream().filter(p -> p.kind().equals("home")).findFirst().orElseThrow(); + assertEquals("Alex's home", home.label()); + assertEquals("[IRON] Iron Wolves", on.pins().stream().filter(p -> p.kind().equals("guild_home")).findFirst().orElseThrow().label()); + } + + @Test void marketPinsSummariseActivity() { + JsonArray market = JsonParser.parseString("[{\"id\":1,\"seller_name\":\"Mia\",\"seller_guild\":null,\"item_name\":\"Diamond\",\"amount\":3,\"price\":99.5}," + + "{\"id\":2,\"seller_name\":\"X\",\"seller_guild\":\"IRON\",\"item_name\":\"Hack\",\"amount\":1,\"price\":5}]").getAsJsonArray(); + ShopPoints.Shop shop = new ShopPoints.Shop("Bazaar", "market", new Pos("minecraft:overworld", 10, 64, 10, 0, 0)); + MapData data = MapModel.build(sources(Map.of(), List.of(shop), market, false)); + String detail = data.pins().stream().filter(p -> p.kind().equals("market")).findFirst().orElseThrow().detail(); + assertTrue(detail.contains("2 listings")); + assertTrue(detail.contains("Diamond x3 · 99.50")); + assertTrue(detail.contains("[IRON] guild")); + assertFalse(detail.contains("Hack"), "listing names can't inject HTML"); + assertTrue(MapModel.build(sources(Map.of(), List.of(shop), new JsonArray(), false)).pins().stream().anyMatch(p -> p.detail().contains("No listings"))); + } + + @Test void playersJsonHasOnlyPublicFacts() { + JsonObject j = MapModel.playersJson("SMP", List.of(new MapData.OnlinePlayer("u-1", "Alex", 12, "Miner", 4, "Iron", "IRON", "leader", "VIP", 7300)), 123L); + JsonObject a = j.getAsJsonObject("players").getAsJsonObject("u-1"); + assertEquals(12, a.get("level").getAsInt()); + assertEquals("IRON", a.get("guildTag").getAsString()); + assertFalse(a.has("balance")); + assertEquals("SMP", j.get("server").getAsString()); + } + + // ---- shop points --------------------------------------------------------------------- + + @Test void shopPointsAreManagedByStaffAndSurviveARestart() { + Kit kit = new Kit(dir); + Kit.Player alex = kit.platform.add("Alex"); + ShopPoints points = new ShopPoints(dir.resolve("shops.json")); + Pos block = new Pos("minecraft:overworld", 10.4, 64.2, -3.7, 0, 0); + alex.denied.add(ShopPoints.NODE); + points.command(alex, new String[]{"add", "Bazaar"}, block); + assertTrue(alex.last().contains("permission")); + alex.denied.clear(); + points.command(alex, new String[]{"add", "bad name"}, block); + assertTrue(alex.last().contains("Names are")); + points.command(alex, new String[]{"add", "Bazaar"}, block); + assertTrue(alex.last().contains("Shop point Bazaar set (market)")); + points.command(alex, new String[]{"add", "Twin", "shop"}, block); + assertTrue(alex.last().contains("already a shop point")); + points.command(alex, new String[]{"add", "Smith", "shop"}, new Pos("minecraft:overworld", 12, 64, -3, 0, 0)); + + assertEquals("Bazaar", points.at("minecraft:overworld", 10, 64, -4).orElseThrow().name(), "block coordinates floor correctly"); + assertTrue(points.at("minecraft:the_nether", 10, 64, -4).isEmpty()); + assertTrue(points.at("minecraft:overworld", 11, 64, -4).isEmpty()); + + ShopPoints reopened = new ShopPoints(dir.resolve("shops.json")); + assertEquals(2, reopened.all().size()); + reopened.command(alex, new String[]{"remove", "bazaar"}, block); + assertEquals(1, reopened.all().size()); + reopened.command(alex, new String[]{"remove", "ghost"}, block); + assertTrue(alex.last().contains("No shop point")); + reopened.command(alex, new String[]{"list"}, block); + assertTrue(alex.heard("Smith (shop)")); + } + + @Test void marketPointCommandUsesTheBlockUnderFoot() { + Kit kit = new Kit(dir); + Kit.Player alex = kit.platform.add("Alex"); + CommandSet set = new CommandSet(kit.env, EssentialsConfig.defaults()); + alex.pos = new Pos("minecraft:overworld", 10.5, 65, 20.5, 0, 0); + set.run("market", alex, "point", "add", "Bazaar"); + assertTrue(set.shops.at("minecraft:overworld", 10, 64, 20).isPresent()); + } + + // ---- actions from the map ------------------------------------------------------------ + + @Test void pollerCarriesOutMessagesNoticesAndTeleportRequests() { + Kit kit = new Kit(dir); + Kit.Player alex = kit.platform.add("Alex"), steve = kit.platform.add("Steve"); + List tpas = new ArrayList<>(); + ActionPoller poller = new ActionPoller(kit.env, (from, to) -> tpas.add(from.name() + "->" + to.name())); + String json = "{\"actions\":[{\"id\":1,\"kind\":\"message\",\"from_uuid\":\"" + alex.id + "\",\"from_name\":\"Alex\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"hello\"}," + + "{\"id\":2,\"kind\":\"tpa\",\"from_uuid\":\"" + alex.id + "\",\"from_name\":\"Alex\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"\"}," + + "{\"id\":3,\"kind\":\"notify\",\"from_uuid\":\"\",\"from_name\":\"\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"§eAlex invited you\"}," + + "{\"id\":4,\"kind\":\"message\",\"from_uuid\":\"\",\"from_name\":\"X\",\"to_uuid\":\"" + UUID.randomUUID() + "\",\"text\":\"to nobody\"}," + + "{\"id\":5,\"kind\":\"explode\",\"from_uuid\":\"\",\"from_name\":\"X\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"\"}]}"; + kit.panel.on("actions/poll", json); + poller.tick(); + assertTrue(steve.heard("[Map] Alex: hello")); + assertEquals(List.of("Alex->Steve"), tpas); + assertTrue(steve.heard("Alex invited you")); + assertEquals(2, steve.inbox.size(), "unknown actions and ones for absent players do nothing"); + + // It asks the panel at most every few seconds, and never overlaps itself. + kit.panel.calls.clear(); + poller.tick(); + poller.tick(); + assertTrue(kit.panel.calls.isEmpty()); + kit.now.addAndGet(ActionPoller.EVERY_MS + 1); + poller.tick(); + assertEquals(1, kit.panel.calls.size()); + } + + @Test void teleportRequestsNeedThePermissionAndAPresentRequester() { + Kit kit = new Kit(dir); + Kit.Player alex = kit.platform.add("Alex"), steve = kit.platform.add("Steve"); + List tpas = new ArrayList<>(); + ActionPoller poller = new ActionPoller(kit.env, (from, to) -> tpas.add(from.name())); + alex.denied.add("scopenet.command.tpa"); + kit.panel.on("actions/poll", "{\"actions\":[{\"id\":1,\"kind\":\"tpa\",\"from_uuid\":\"" + alex.id + "\",\"from_name\":\"Alex\",\"to_uuid\":\"" + steve.id + "\",\"text\":\"\"}]}"); + poller.tick(); + assertTrue(tpas.isEmpty()); + assertTrue(alex.last().contains("do not have permission")); + + kit.platform.online.remove(alex.id); + kit.now.addAndGet(ActionPoller.EVERY_MS + 1); + poller.tick(); + assertTrue(steve.heard("has left")); + } + + @Test void thePollerSurvivesAPanelOutage() { + Kit kit = new Kit(dir); + kit.platform.add("Alex"); + ActionPoller poller = new ActionPoller(kit.env, (a, b) -> { }); + kit.panel.handlers.put("actions/poll", b -> { throw new java.io.IOException("down"); }); + poller.tick(); + kit.now.addAndGet(ActionPoller.EVERY_MS + 1); + kit.panel.on("actions/poll", "{\"actions\":[]}"); + poller.tick(); + assertEquals(2, kit.panel.calls.size()); + } + + // ---- guild invitations in game ----------------------------------------------------------- + + @Test void guildInviteAcceptAndDeclineCommands() { + Kit kit = new Kit(dir); + Kit.Player alex = kit.platform.add("Alex"); + CommandSet set = new CommandSet(kit.env, EssentialsConfig.defaults()); + kit.panel.on("guilds/invite/send", "{\"ok\":true,\"player\":\"Steve\"}"); + set.run("guild", alex, "invite", "steve"); + assertTrue(alex.heard("Invitation sent to Steve")); + assertEquals("steve", kit.panel.bodies.get(kit.panel.bodies.size() - 1).get("target").getAsString()); + set.run("guild", alex, "invite"); + assertTrue(alex.last().startsWith("Usage")); + + kit.panel.on("guilds/invite/respond", "{\"ok\":true,\"accepted\":true,\"guild\":\"Iron\",\"tag\":\"IRON\"}"); + set.run("guild", alex, "accept", "IRON"); + assertTrue(alex.heard("Welcome to [IRON] Iron")); + assertTrue(kit.panel.claimsRefreshed); + assertEquals("IRON", kit.panel.bodies.get(kit.panel.bodies.size() - 1).get("tag").getAsString()); + kit.panel.on("guilds/invite/respond", "{\"ok\":true,\"accepted\":false,\"guild\":\"Iron\",\"tag\":\"IRON\"}"); + set.run("guild", alex, "decline"); + assertTrue(alex.last().contains("declined")); + alex.denied.add("scopenet.command.guild.invite"); + set.run("guild", alex, "invite", "x"); + assertTrue(alex.last().contains("permission to use /guild invite")); + } + + @Test void mapServiceAssemblesEverythingOnTheServerSide() { + Kit kit = new Kit(dir); + Kit.Player alex = kit.platform.add("Alex"); + kit.panel.on("player/info", LinkTest.INFO); + CommandSet set = new CommandSet(kit.env, EssentialsConfig.defaults()); + PlayerCache cache = new PlayerCache(kit.env); + cache.refresh(alex, null); + alex.pos = new Pos("minecraft:overworld", 100, 65, 100, 0, 0); + set.essentials.setWarp(alex, "Arena"); + set.run("market", alex, "point", "add", "Bazaar"); + kit.panel.on("economy/market", "[{\"id\":1,\"seller_name\":\"Mia\",\"item_name\":\"Diamond\",\"amount\":1,\"price\":5}]"); + ClaimIndex claims = new ClaimIndex(); + claims.apply(JsonParser.parseString("{\"revision\":\"1\",\"guilds\":[{\"id\":\"g1\",\"name\":\"Iron\",\"tag\":\"IRON\",\"icon_url\":\"/textures/x\"}]," + + "\"claims\":[[\"minecraft:overworld\",0,0,0],[\"minecraft:overworld\",1,0,0]],\"members\":{\"g1\":[]}}").getAsJsonObject()); + MapService service = new MapService(kit.env, claims, set, cache, "https://panel.example.com", false, "SMP"); + service.tick(); + MapData data = service.snapshot(); + assertEquals(1, data.claims().size()); + assertEquals("https://panel.example.com/textures/x", data.claims().get(0).icon()); + assertEquals(2, data.claims().get(0).chunks()); + Set kinds = new TreeSet<>(); + data.pins().forEach(p -> kinds.add(p.kind())); + assertEquals(new TreeSet<>(List.of("spawn", "warp", "market")), kinds); + assertTrue(data.pins().stream().filter(p -> p.kind().equals("market")).findFirst().orElseThrow().detail().contains("1 listing")); + assertEquals("Alex", data.players().get(0).name()); + assertEquals(12, data.players().get(0).level()); + assertEquals("IRON", service.playersJson().getAsJsonObject("players").getAsJsonObject(alex.id.toString()).get("guildTag").getAsString()); + + // Turning claims off hides them. + kit.env.features = new Features(true, true, true, true, false, true, "$"); + assertTrue(service.snapshot().claims().isEmpty()); + } +}