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);
}
/** 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;
@@ -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<CoreCommand> commands = new ArrayList<>();
public CommandSet(Env env, EssentialsConfig essentialsConfig) {
Path dir = env.dataDir;
essentials = new EssentialsService(env.platform, dir.resolve("essentials.json"), essentialsConfig, env.clock, env.rng);
jobs = new Jobs(env, dir.resolve("economy-pending.json"));
EconomyCommands economy = new EconomyCommands(env, jobs);
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<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. */
public void tick() { jobs.tick(); }
@@ -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<CoreCommand> build() {
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("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;
@@ -132,6 +132,16 @@ public final class EssentialsService {
// ---- 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 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. */
final class GuildCommands {
private static final Gson GSON = new Gson();
private static final Set<String> GATED = Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay");
private 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 Jobs jobs;
@@ -39,7 +39,8 @@ final class GuildCommands {
List<CoreCommand> build() {
List<CoreCommand> out = new ArrayList<>();
out.add(Cmd.of("guild", List.of("g", "clan"), this::guild).completing((p, a) -> a.length <= 1
? List.of("info", "create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay") : List.of()));
? 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 <amount>]", "Guild money"},
{"/guild sell [hand]", "Sell items to the shop for the guild bank"}, {"/guild market sell <price> | buy <#id>", "Trade on the market as a guild"},
{"/guild pay <tag> <amount>", "Pay another guild"},
{"/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]);
}
@@ -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 ---------------------------------------------------------------
private void bank(CorePlayer p, String[] args) {
@@ -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();
}
@@ -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.
*/
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) {}
@@ -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<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. */
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<String, Map<Long, Guild>> claims = new HashMap<>();
JsonArray claimList = response.getAsJsonArray("claims");