Map core: claim outlines, pins, shop points, action poller, guild invite commands
This commit is contained in:
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("&");
|
||||
case '<' -> out.append("<");
|
||||
case '>' -> out.append(">");
|
||||
case '"' -> out.append(""");
|
||||
case '\'' -> out.append("'");
|
||||
default -> { if (c >= 32 || c == '\n') out.append(c); }
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/** A stable, pleasant colour per guild: hue from the id, fixed saturation and lightness. */
|
||||
public static int guildColor(String guildId) {
|
||||
int h = guildId.hashCode();
|
||||
double hue = Math.floorMod(h, 360);
|
||||
return hsl(hue, 0.65, 0.55);
|
||||
}
|
||||
|
||||
static int hsl(double h, double s, double l) {
|
||||
double c = (1 - Math.abs(2 * l - 1)) * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = l - c / 2;
|
||||
double r, g, b;
|
||||
if (h < 60) { r = c; g = x; b = 0; } else if (h < 120) { r = x; g = c; b = 0; } else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; } else if (h < 300) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; }
|
||||
return ((int) Math.round((r + m) * 255) << 16) | ((int) Math.round((g + m) * 255) << 8) | (int) Math.round((b + m) * 255);
|
||||
}
|
||||
|
||||
/** The icon address: a full web address as is, a path on the panel made absolute, empty if none. */
|
||||
public static String iconAddress(String icon, String panelBase) {
|
||||
if (icon == null || icon.isBlank()) return "";
|
||||
String i = icon.trim();
|
||||
if (i.startsWith("https://") || i.startsWith("http://")) return i;
|
||||
if (i.startsWith("/") && panelBase != null && !panelBase.isBlank()) return panelBase.replaceAll("/+$", "") + i;
|
||||
return "";
|
||||
}
|
||||
|
||||
public static MapData build(Sources s) {
|
||||
List<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(" · ").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");
|
||||
|
||||
Reference in new issue
Block a user