Map core: claim outlines, pins, shop points, action poller, guild invite commands

This commit is contained in:
Claude committed 2026-09-30 20:19:48 +00:00
1 parent e160ad8da3
commit ad7285efac
14 files changed
+1004 -11

No files matched your search

@@ -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<CorePlayer> 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<CorePlayer> 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<CorePlayer> 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 -> { }
}
}
}
@@ -189,6 +189,16 @@ public final class ClientLink {
send(p, m); 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…). */ /** A toast for the client (level-up, achievement, friend request…). */
public void notify(CorePlayer p, String kind, String title, String text) { public void notify(CorePlayer p, String kind, String title, String text) {
if (!clients.contains(p.uuid())) return; if (!clients.contains(p.uuid())) return;
@@ -8,14 +8,20 @@ import java.util.List;
public final class CommandSet { public final class CommandSet {
public final EssentialsService essentials; public final EssentialsService essentials;
public final Jobs jobs; public final Jobs jobs;
public final ShopPoints shops;
private final GuildCommands guildCommands;
private final EconomyCommands economyCommands;
private final List<CoreCommand> commands = new ArrayList<>(); private final List<CoreCommand> commands = new ArrayList<>();
public CommandSet(Env env, EssentialsConfig essentialsConfig) { public CommandSet(Env env, EssentialsConfig essentialsConfig) {
Path dir = env.dataDir; Path dir = env.dataDir;
essentials = new EssentialsService(env.platform, dir.resolve("essentials.json"), essentialsConfig, env.clock, env.rng); essentials = new EssentialsService(env.platform, dir.resolve("essentials.json"), essentialsConfig, env.clock, env.rng);
jobs = new Jobs(env, dir.resolve("economy-pending.json")); 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")); 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, EssentialsCommands.build(env, essentials), "Essentials", () -> env.features.essentials());
add(env, economy.build(), "Economy", () -> env.features.economy()); add(env, economy.build(), "Economy", () -> env.features.economy());
add(env, guilds.build(), "Guilds", () -> env.features.guilds()); add(env, guilds.build(), "Guilds", () -> env.features.guilds());
@@ -27,6 +33,15 @@ public final class CommandSet {
public List<CoreCommand> all() { return List.copyOf(commands); } public List<CoreCommand> 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<String, Pos> guildHomes() { return guildCommands.guildHomes(); }
/** Call about once a second from the server thread. */ /** Call about once a second from the server thread. */
public void tick() { jobs.tick(); } public void tick() { jobs.tick(); }
@@ -14,8 +14,9 @@ import java.util.*;
final class EconomyCommands { final class EconomyCommands {
private final Env env; private final Env env;
private final Jobs jobs; 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<CoreCommand> build() { List<CoreCommand> build() {
List<CoreCommand> out = new ArrayList<>(); List<CoreCommand> 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("baltop", List.of("richest"), (p, a) -> baltop(p)));
out.add(Cmd.of("shop", List.of(), (p, a) -> shop(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("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("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("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))); 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. */ /** {@code asGuild}: list on behalf of the player's guild, or buy with its bank. */
void market(CorePlayer p, String[] args, boolean asGuild) { 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 (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; } 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; long listing;
@@ -132,6 +132,16 @@ public final class EssentialsService {
// ---- warps ------------------------------------------------------------- // ---- warps -------------------------------------------------------------
/** Every warp, for the map. A copy. */
public Map<String, Pos> allWarps() { return new TreeMap<>(warps); }
/** Every player's homes, for the map (only shown when the admin turns that on). A copy. */
public Map<UUID, Map<String, Pos>> allHomes() {
Map<UUID, Map<String, Pos>> out = new HashMap<>();
homes.forEach((id, h) -> out.put(id, new TreeMap<>(h)));
return out;
}
public List<String> warpNames() { return new ArrayList<>(new TreeSet<>(warps.keySet())); } public List<String> warpNames() { return new ArrayList<>(new TreeSet<>(warps.keySet())); }
public Optional<Pos> warpAt(String name) { return Optional.ofNullable(warps.get(name.toLowerCase(Locale.ROOT))); } public Optional<Pos> warpAt(String name) { return Optional.ofNullable(warps.get(name.toLowerCase(Locale.ROOT))); }
@@ -15,7 +15,7 @@ import java.util.concurrent.ConcurrentHashMap;
/** /guild (and its subcommands), /claim, /unclaim: same behaviour and permission nodes as the Paper plugin. */ /** /guild (and its subcommands), /claim, /unclaim: same behaviour and permission nodes as the Paper plugin. */
final class GuildCommands { final class GuildCommands {
private static final Gson GSON = new Gson(); 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 static final Set<String> 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 Env env;
private final Jobs jobs; private final Jobs jobs;
@@ -39,7 +39,8 @@ final class GuildCommands {
List<CoreCommand> build() { List<CoreCommand> build() {
List<CoreCommand> out = new ArrayList<>(); List<CoreCommand> out = new ArrayList<>();
out.add(Cmd.of("guild", List.of("g", "clan"), this::guild).completing((p, a) -> a.length <= 1 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("claim", List.of(), (p, a) -> claim(p)));
out.add(Cmd.of("unclaim", List.of(), (p, a) -> unclaim(p))); out.add(Cmd.of("unclaim", List.of(), (p, a) -> unclaim(p)));
return out; return out;
@@ -78,6 +79,9 @@ final class GuildCommands {
case "members" -> members(p); case "members" -> members(p);
case "bank" -> bank(p, rest); case "bank" -> bank(p, rest);
case "pay" -> pay(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 "sell" -> economy.sell(p, rest, true);
case "market" -> economy.market(p, rest, true); case "market" -> economy.market(p, rest, true);
default -> help(p); default -> help(p);
@@ -93,6 +97,7 @@ final class GuildCommands {
{"/guild sethome / /guild home", "Guild waypoint base"}, {"/guild bank [deposit|withdraw <amount>]", "Guild money"}, {"/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 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"}, {"/guild pay <tag> <amount>", "Pay another guild"},
{"/guild invite <player>", "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]); 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 <player>"); 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<String, Pos> guildHomes() { return Map.copyOf(guildHomes); }
// ---- bank --------------------------------------------------------------- // ---- bank ---------------------------------------------------------------
private void bank(CorePlayer p, String[] args) { private void bank(CorePlayer p, String[] args) {
@@ -59,22 +59,22 @@ public final class PlayerCache {
// ---- small accessors used by placeholders and the client link ----------------------------- // ---- 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); JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonObject() ? e.getAsJsonObject() : null; 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); JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonPrimitive() && e.getAsJsonPrimitive().isNumber() ? e.getAsDouble() : fallback; 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); JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonPrimitive() ? e.getAsString() : fallback; 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); JsonElement e = o == null ? null : o.get(key);
return e != null && e.isJsonArray() ? e.getAsJsonArray() : new JsonArray(); return e != null && e.isJsonArray() ? e.getAsJsonArray() : new JsonArray();
} }
@@ -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<String, Shop> shops = new ConcurrentHashMap<>();
public ShopPoints(Path file) {
this.file = file;
if (Files.exists(file)) {
try (Reader in = Files.newBufferedReader(file)) {
List<Shop> loaded = GSON.fromJson(in, new TypeToken<List<Shop>>() {}.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<Shop> all() { return new ArrayList<>(new TreeMap<>(shops).values()); }
/** The shop at this block, if any. */
public Optional<Shop> 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 <name> [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 <name>} 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 <name> [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 <name>"); 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 <name>.");
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 <name> [market|shop] | remove <name> | list");
}
}
}
@@ -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<Point> 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<Ring> 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<Set<Long>> components(Set<Long> chunks) {
List<Set<Long>> out = new ArrayList<>();
Set<Long> seen = new HashSet<>();
List<Long> sorted = new ArrayList<>(chunks);
Collections.sort(sorted);
for (long start : sorted) {
if (!seen.add(start)) continue;
Set<Long> group = new HashSet<>();
Deque<Long> 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<Region> regions(Set<Long> chunks) {
List<Region> out = new ArrayList<>();
for (Set<Long> group : components(chunks)) {
List<Ring> 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<Ring> 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<Long> 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<Ring> outline(Set<Long> group) {
// Directed edges with the chunk on the right-hand side (clockwise around solid land).
Map<Long, List<Edge>> 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<Ring> 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<int[]> 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<Long, List<Edge>> m, Edge e) { m.computeIfAbsent(key(e.fx(), e.fz()), k -> new ArrayList<>()).add(e); }
private static void remove(Map<Long, List<Edge>> m, Edge e) {
List<Edge> 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<Long, List<Edge>> m, Edge e) {
List<Edge> 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<Point> simplify(List<int[]> loop) {
List<int[]> 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<Point> out = new ArrayList<>();
for (int[] p : kept) out.add(new Point(p[0] * 16.0, p[1] * 16.0));
return out;
}
}
@@ -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<ClaimRegion> claims, List<Pin> pins, List<OnlinePlayer> 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<ChunkRegions.Ring> 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) {}
}
@@ -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<String, List<ClaimIndex.GuildClaims>> claims, Map<String, Pos> warps, Collection<Pos> spawns,
Map<UUID, Map<String, Pos>> homes, Map<String, NamedPos> guildHomes, Collection<ShopPoints.Shop> shops,
JsonArray market, List<MapData.OnlinePlayer> players, String panelBase, boolean showHomes,
Map<UUID, String> 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("&amp;");
case '<' -> out.append("&lt;");
case '>' -> out.append("&gt;");
case '"' -> out.append("&quot;");
case '\'' -> out.append("&#39;");
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<MapData.ClaimRegion> 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 = "<div class=\"scopenet-detail\"><strong>" + escape(g.name()) + "</strong> <span>[" + escape(g.tag()) + "]</span><br>"
+ r.chunks() + " claimed chunk" + (r.chunks() == 1 ? "" : "s") + " in this area</div>";
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<MapData.Pin> 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(),
"<div class=\"scopenet-detail\"><strong>World spawn</strong></div>"));
s.warps().forEach((name, p) -> pins.add(new MapData.Pin("warp-" + name, "warp", "Warp: " + name, p.world(), p.x(), p.y(), p.z(),
"<div class=\"scopenet-detail\"><strong>Warp</strong> " + escape(name) + "<br><code>/warp " + escape(name) + "</code></div>")));
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(), "<div class=\"scopenet-detail\"><strong>" + escape(home.label()) + "</strong><br>Guild home</div>")));
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(),
"<div class=\"scopenet-detail\"><strong>" + escape(who) + "</strong><br>Home: " + escape(name) + "</div>"));
}));
}
String marketDetail = marketSummary(s.market());
for (ShopPoints.Shop shop : s.shops()) {
boolean market = shop.kind().equals("market");
String detail = "<div class=\"scopenet-detail\"><strong>" + escape(shop.name()) + "</strong><br>"
+ (market ? "Player market. Right-click to browse and trade." + marketDetail : "Server shop. Right-click to buy and sell.") + "</div>";
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 "<br><em>No listings right now.</em>";
StringBuilder b = new StringBuilder("<br><strong>" + listings.size() + " listing" + (listings.size() == 1 ? "" : "s") + "</strong><ul class=\"scopenet-list\">");
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("<li>").append(escape(text(l, "item_name"))).append(" x").append(l.has("amount") ? l.get("amount").getAsInt() : 1)
.append(" &middot; ").append(String.format(Locale.US, "%,.2f", l.has("price") ? l.get("price").getAsDouble() : 0))
.append(" <span>by ").append(escape(guild)).append("</span></li>");
}
return b.append("</ul>").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<MapData.OnlinePlayer> 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;
}
}
@@ -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<MapData.OnlinePlayer> players = onlinePlayers();
Map<UUID, String> names = new HashMap<>();
for (CorePlayer p : env.platform.online()) names.put(p.uuid(), p.name());
Map<String, MapModel.NamedPos> guildHomes = new TreeMap<>();
Map<String, String> 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<MapData.OnlinePlayer> onlinePlayers() {
List<MapData.OnlinePlayer> 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()); }
}
@@ -16,7 +16,7 @@ import java.util.UUID;
* The panel is polled for a new copy only when its revision changes. * The panel is polled for a new copy only when its revision changes.
*/ */
public final class ClaimIndex { 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<String, Map<Long, Guild>> claims, Map<String, Set<UUID>> members, int total) {} private record Snapshot(String revision, Map<String, Map<Long, Guild>> claims, Map<String, Set<UUID>> members, int total) {}
@@ -59,6 +59,26 @@ public final class ClaimIndex {
return new ChunkCheckResult(true, members != null && members.contains(player), guild.name(), guild.tag()); 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<Long> 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<String, java.util.List<GuildClaims>> byDimension() {
Snapshot s = snapshot;
Map<String, java.util.List<GuildClaims>> out = new HashMap<>();
if (s == null) return out;
s.claims().forEach((dimension, chunks) -> {
Map<String, GuildClaims> 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. */ /** Applies a {@code guilds/claim-index} response. Returns true when the contents changed. */
public boolean apply(JsonObject response) { public boolean apply(JsonObject response) {
if (response.has("unchanged") && response.get("unchanged").getAsBoolean()) return false; if (response.has("unchanged") && response.get("unchanged").getAsBoolean()) return false;
@@ -67,7 +87,8 @@ public final class ClaimIndex {
Guild[] guilds = new Guild[guildList.size()]; Guild[] guilds = new Guild[guildList.size()];
for (int i = 0; i < guilds.length; i++) { for (int i = 0; i < guilds.length; i++) {
JsonObject g = guildList.get(i).getAsJsonObject(); 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<String, Map<Long, Guild>> claims = new HashMap<>(); Map<String, Map<Long, Guild>> claims = new HashMap<>();
JsonArray claimList = response.getAsJsonArray("claims"); JsonArray claimList = response.getAsJsonArray("claims");
@@ -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<Long> chunks(String... rows) {
Set<Long> 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<ChunkRegions.Region> 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<ChunkRegions.Region> 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<ChunkRegions.Region> 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<ChunkRegions.Region> 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<ChunkRegions.Region> apart = ChunkRegions.regions(chunks("#.....#"));
assertEquals(2, apart.size());
List<ChunkRegions.Region> 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<Long> 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<Long> 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<Long> 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<Long> c) { return new ClaimIndex.GuildClaims(id, name, tag, icon, c); }
static MapModel.Sources sources(Map<String, List<ClaimIndex.GuildClaims>> claims, Collection<ShopPoints.Shop> 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<String, List<ClaimIndex.GuildClaims>> claims = Map.of("minecraft:overworld", List.of(
guild("g1", "Iron <Wolves>", "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("<Wolves>"), "names are HTML-escaped: " + iron.detail());
assertTrue(iron.detail().contains("&lt;Wolves&gt;"));
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<ShopPoints.Shop> 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<String> 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\":\"<b>Hack</b>\",\"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 &middot; 99.50"));
assertTrue(detail.contains("[IRON] guild"));
assertFalse(detail.contains("<b>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<String> 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<String> 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<String> 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());
}
}