diff --git a/integrations/common/src/main/java/net/scopenet/core/ClientLink.java b/integrations/common/src/main/java/net/scopenet/core/ClientLink.java new file mode 100644 index 0000000..d5e1acf --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/ClientLink.java @@ -0,0 +1,206 @@ +package net.scopenet.core; + +import com.google.gson.*; +import net.scopenet.integration.ChunkCheckResult; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; + +/** + * Talks to the optional SCOPENET client mod over the {@link Wire} channels. The client says hello; from then on this + * pushes that player's state (level, balance, guild, quests), the claims around them, and answers market and shop + * requests. Display only: every action the client takes is an ordinary command, checked like any other. + */ +public final class ClientLink { + private static final int RADIUS = 4; + + private final Env env; + private final PlayerCache cache; + private final BiConsumer out; + private final String serverName; + private final Map lastState = new ConcurrentHashMap<>(); + private final Map lastClaimsKey = new ConcurrentHashMap<>(); + private final Set clients = ConcurrentHashMap.newKeySet(); + + public ClientLink(Env env, PlayerCache cache, String serverName, BiConsumer out) { + this.env = env; this.cache = cache; this.serverName = serverName; this.out = out; + } + + public boolean hasClient(UUID uuid) { return clients.contains(uuid); } + + public void disconnected(UUID uuid) { + clients.remove(uuid); lastState.remove(uuid); lastClaimsKey.remove(uuid); cache.forget(uuid); + } + + private void send(CorePlayer p, JsonObject message) { out.accept(p, Wire.encode(message.toString())); } + + /** A packet from a player's client. Unknown or malformed messages are ignored. */ + public void receive(CorePlayer p, byte[] payload) { + JsonObject msg; + try { msg = JsonParser.parseString(Wire.decode(payload)).getAsJsonObject(); } + catch (RuntimeException e) { return; } + String type = PlayerCache.str(msg, "t", ""); + if (!env.features.clientLink()) return; + switch (type) { + case "hello" -> hello(p); + case "refresh" -> { if (clients.contains(p.uuid())) { lastState.remove(p.uuid()); cache.refresh(p, info -> pushState(p)); } } + case "claims" -> { if (clients.contains(p.uuid())) { lastClaimsKey.remove(p.uuid()); pushClaims(p); } } + case "market" -> { if (clients.contains(p.uuid())) market(p); } + case "shop" -> { if (clients.contains(p.uuid())) shop(p); } + default -> { } + } + } + + private void hello(CorePlayer p) { + clients.add(p.uuid()); + Features f = env.features; + JsonObject m = new JsonObject(); + m.addProperty("t", "hello"); + m.addProperty("v", Wire.VERSION); + m.addProperty("server", serverName); + m.addProperty("currency", f.currencySymbol()); + JsonObject features = new JsonObject(); + features.addProperty("essentials", f.essentials()); + features.addProperty("economy", f.economy()); + features.addProperty("guilds", f.guilds()); + features.addProperty("claims", f.guilds() && f.landClaiming()); + features.addProperty("social", f.social()); + m.add("features", features); + send(p, m); + cache.refresh(p, info -> pushState(p)); + if (cache.info(p.uuid()) != null) pushState(p); + pushClaims(p); + } + + /** Push the state if it changed since the last push. */ + public void pushState(CorePlayer p) { + if (!clients.contains(p.uuid())) return; + JsonObject i = cache.info(p.uuid()); + if (i == null) return; + JsonObject global = PlayerCache.obj(i, "global"), server = PlayerCache.obj(i, "server"), guild = PlayerCache.obj(i, "guild"); + JsonObject quests = PlayerCache.obj(i, "quests"); + JsonObject m = new JsonObject(); + m.addProperty("t", "state"); + m.addProperty("name", p.name()); + m.addProperty("level", (long) PlayerCache.num(global, "level", 1)); + m.addProperty("xp", (long) PlayerCache.num(global, "xp", 0)); + m.addProperty("xpNow", (long) PlayerCache.num(global, "current_level_xp", 0)); + m.addProperty("xpNext", (long) PlayerCache.num(global, "next_level_xp", 0)); + m.addProperty("xpPct", PlayerCache.num(global, "progress_pct", 0)); + m.addProperty("title", PlayerCache.str(global, "title", "")); + m.addProperty("serverLevel", (long) PlayerCache.num(server, "level", 1)); + m.addProperty("serverXpPct", PlayerCache.num(server, "progress_pct", 0)); + if (guild != null) { + JsonObject g = new JsonObject(); + g.addProperty("name", PlayerCache.str(guild, "name", "")); + g.addProperty("tag", PlayerCache.str(guild, "tag", "")); + g.addProperty("role", PlayerCache.str(guild, "role", "")); + g.addProperty("claims", (long) PlayerCache.num(guild, "claims", 0)); + m.add("guild", g); + } + m.addProperty("balance", PlayerCache.num(i, "balance", 0)); + m.addProperty("playtime", (long) PlayerCache.num(i, "playtime_secs", 0)); + for (String period : List.of("daily", "weekly")) { + JsonObject q = PlayerCache.obj(quests, period); + JsonObject o = new JsonObject(); + o.addProperty("done", (long) PlayerCache.num(q, "completed", 0)); + o.addProperty("total", (long) PlayerCache.num(q, "total", 0)); + m.add(period, o); + } + JsonObject rank = PlayerCache.obj(i, "rank"); + if (rank != null) m.addProperty("rank", PlayerCache.str(rank, "display", PlayerCache.str(rank, "group", ""))); + m.addProperty("kills", (long) PlayerCache.num(i, "kills", 0)); + m.addProperty("deaths", (long) PlayerCache.num(i, "deaths", 0)); + String text = m.toString(); + if (text.equals(lastState.put(p.uuid(), text))) return; + send(p, m); + } + + /** Claims around the player; sent when they change chunk or the claim map changes. */ + public void pushClaims(CorePlayer p) { + if (!clients.contains(p.uuid()) || !env.features.guilds() || !env.features.landClaiming()) return; + Pos pos = p.pos(); + int cx = pos.blockX() >> 4, cz = pos.blockZ() >> 4; + StringBuilder cells = new StringBuilder(); + for (int dz = -RADIUS; dz <= RADIUS; dz++) { + for (int dx = -RADIUS; dx <= RADIUS; dx++) { + ChunkCheckResult r = env.panel.check(pos.world(), cx + dx, cz + dz, p.uuid()); + cells.append(!r.claimed() ? '0' : r.allowed() ? '1' : '2'); + } + } + ChunkCheckResult here = env.panel.check(pos.world(), cx, cz, p.uuid()); + String key = pos.world() + ":" + cx + ":" + cz + ":" + cells; + if (key.equals(lastClaimsKey.put(p.uuid(), key))) return; + JsonObject m = new JsonObject(); + m.addProperty("t", "claims"); + m.addProperty("dim", pos.world()); + m.addProperty("cx", cx); + m.addProperty("cz", cz); + m.addProperty("r", RADIUS); + m.addProperty("cells", cells.toString()); + if (here.claimed()) { + JsonObject owner = new JsonObject(); + owner.addProperty("name", here.guildName() == null ? "" : here.guildName()); + owner.addProperty("tag", here.guildTag() == null ? "" : here.guildTag()); + owner.addProperty("mine", here.allowed()); + m.add("owner", owner); + } + send(p, m); + } + + private void market(CorePlayer p) { + env.io(() -> env.panel.call("economy/market", new JsonObject()), el -> { + JsonObject m = new JsonObject(); + m.addProperty("t", "market"); + m.add("listings", el.isJsonArray() ? el.getAsJsonArray() : new JsonArray()); + m.addProperty("balance", PlayerCache.num(cache.info(p.uuid()), "balance", 0)); + send(p, m); + }, e -> notify(p, "error", "Market", "Could not load listings: " + e)); + } + + private void shop(CorePlayer p) { + JsonObject m = new JsonObject(); + m.addProperty("t", "shop"); + JsonArray items = new JsonArray(); + ItemValues.all().forEach((id, price) -> { + JsonObject o = new JsonObject(); + o.addProperty("id", id); + o.addProperty("name", id.toLowerCase(Locale.ROOT).replace('_', ' ')); + o.addProperty("price", price); + items.add(o); + }); + m.add("items", items); + p.heldItem().ifPresent(h -> { + JsonObject held = new JsonObject(); + held.addProperty("id", h.id()); + held.addProperty("name", h.name()); + held.addProperty("count", h.count()); + Double unit = ItemValues.unitPrice(h.id()); + if (unit != null) held.addProperty("unit", unit); + m.add("held", held); + }); + send(p, m); + } + + /** 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; + JsonObject m = new JsonObject(); + m.addProperty("t", "notify"); + m.addProperty("kind", kind); + m.addProperty("title", title); + m.addProperty("text", text); + send(p, m); + } + + /** About once a second: new chunk, new claims, or new state for every client. */ + public void tick() { + for (UUID id : new ArrayList<>(clients)) { + Optional p = env.platform.player(id); + if (p.isEmpty()) { disconnected(id); continue; } + pushClaims(p.get()); + pushState(p.get()); + } + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/Placeholders.java b/integrations/common/src/main/java/net/scopenet/core/Placeholders.java new file mode 100644 index 0000000..3d653cb --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Placeholders.java @@ -0,0 +1,95 @@ +package net.scopenet.core; + +import com.google.gson.JsonObject; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +/** + * %scopenet_*% values, the same names and formats as the Paper PlaceholderAPI expansion. On Fabric they appear as + * %scopenet:level% (Placeholder API). Reads the {@link PlayerCache}, so it never blocks. + */ +public final class Placeholders { + public static final List KEYS = List.of( + "level", "xp", "level_xp", "next_level_xp", "level_progress", "level_bar", "title", "leaderboard_rank", + "server_level", "server_xp", "server_rank", "guild", "guild_tag", "guild_role", "guild_claims", "has_guild", + "balance", "balance_formatted", "playtime", "playtime_seconds", "playtime_hours", "server_playtime", + "quest_progress", "quest_daily", "quest_weekly", "quest_daily_done", "quest_daily_total", "quest_weekly_done", "quest_weekly_total", + "kills", "deaths", "kdr", "friends", "achievements", "joined", "rank", "rank_prefix"); + + private final PlayerCache cache; + private final Env env; + + public Placeholders(Env env, PlayerCache cache) { this.env = env; this.cache = cache; } + + /** Empty means "not one of ours". Before the first panel answer, sensible zero values are returned. */ + public Optional resolve(UUID player, String key) { + String k = key.toLowerCase(Locale.ROOT); + if (!KEYS.contains(k)) return Optional.empty(); + JsonObject i = cache.info(player); + JsonObject global = PlayerCache.obj(i, "global"), server = PlayerCache.obj(i, "server"), guild = PlayerCache.obj(i, "guild"); + JsonObject quests = PlayerCache.obj(i, "quests"), daily = PlayerCache.obj(quests, "daily"), weekly = PlayerCache.obj(quests, "weekly"); + JsonObject rank = PlayerCache.obj(i, "rank"); + double balance = PlayerCache.num(i, "balance", 0); + long playtime = (long) PlayerCache.num(i, "playtime_secs", 0); + long kills = (long) PlayerCache.num(i, "kills", 0), deaths = (long) PlayerCache.num(i, "deaths", 0); + int dDone = (int) PlayerCache.num(daily, "completed", 0), dTotal = (int) PlayerCache.num(daily, "total", 0); + int wDone = (int) PlayerCache.num(weekly, "completed", 0), wTotal = (int) PlayerCache.num(weekly, "total", 0); + String symbol = env.features.currencySymbol(); + String out = switch (k) { + case "level" -> String.valueOf((long) PlayerCache.num(global, "level", 1)); + case "xp" -> String.valueOf((long) PlayerCache.num(global, "xp", 0)); + case "level_xp" -> String.valueOf((long) PlayerCache.num(global, "current_level_xp", 0)); + case "next_level_xp" -> String.valueOf((long) PlayerCache.num(global, "next_level_xp", 0)); + case "level_progress" -> String.valueOf(Math.round(PlayerCache.num(global, "progress_pct", 0))); + case "level_bar" -> bar(PlayerCache.num(global, "progress_pct", 0)); + case "title" -> PlayerCache.str(global, "title", ""); + case "leaderboard_rank" -> { long r = (long) PlayerCache.num(global, "rank", 0); yield r <= 0 ? "-" : String.valueOf(r); } + case "server_level" -> String.valueOf((long) PlayerCache.num(server, "level", 1)); + case "server_xp" -> String.valueOf((long) PlayerCache.num(server, "xp", 0)); + case "server_rank" -> PlayerCache.str(server, "rank_name", ""); + case "guild" -> PlayerCache.str(guild, "name", ""); + case "guild_tag" -> PlayerCache.str(guild, "tag", ""); + case "guild_role" -> PlayerCache.str(guild, "role", ""); + case "guild_claims" -> String.valueOf((long) PlayerCache.num(guild, "claims", 0)); + case "has_guild" -> String.valueOf(guild != null); + case "balance" -> String.format(Locale.US, "%.2f", balance); + case "balance_formatted" -> Format.money(symbol, balance); + case "playtime" -> duration(playtime); + case "playtime_seconds" -> String.valueOf(playtime); + case "playtime_hours" -> String.valueOf(playtime / 3600); + case "server_playtime" -> duration((long) PlayerCache.num(i, "server_playtime_secs", 0)); + case "quest_progress" -> (dDone + wDone) + "/" + (dTotal + wTotal); + case "quest_daily" -> dDone + "/" + dTotal; + case "quest_weekly" -> wDone + "/" + wTotal; + case "quest_daily_done" -> String.valueOf(dDone); + case "quest_daily_total" -> String.valueOf(dTotal); + case "quest_weekly_done" -> String.valueOf(wDone); + case "quest_weekly_total" -> String.valueOf(wTotal); + case "kills" -> String.valueOf(kills); + case "deaths" -> String.valueOf(deaths); + case "kdr" -> String.format(Locale.US, "%.2f", deaths == 0 ? (double) kills : (double) kills / deaths); + case "friends" -> String.valueOf((long) PlayerCache.num(i, "friends", 0)); + case "achievements" -> String.valueOf((long) PlayerCache.num(i, "achievements", 0)); + case "joined" -> { String j = PlayerCache.str(i, "joined", ""); yield j.length() >= 10 ? j.substring(0, 10) : j; } + case "rank" -> PlayerCache.str(rank, "group", ""); + case "rank_prefix" -> PlayerCache.str(rank, "prefix", ""); + default -> ""; + }; + return Optional.of(out); + } + + static String bar(double percent) { + int filled = (int) Math.max(0, Math.min(10, Math.round(percent / 10.0))); + return "§a" + "■".repeat(filled) + "§7" + "■".repeat(10 - filled); + } + + static String duration(long seconds) { + long d = seconds / 86400, h = seconds % 86400 / 3600, m = seconds % 3600 / 60; + if (d > 0) return d + "d " + h + "h"; + if (h > 0) return h + "h " + m + "m"; + return m + "m"; + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/PlayerCache.java b/integrations/common/src/main/java/net/scopenet/core/PlayerCache.java new file mode 100644 index 0000000..f12e900 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/PlayerCache.java @@ -0,0 +1,81 @@ +package net.scopenet.core; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The panel's view of each online player (level, guild, balance, quests…), refreshed in the background so + * placeholders and the client HUD never wait on the network. + */ +public final class PlayerCache { + public static final long REFRESH_MS = 20_000; + + private record Entry(JsonObject info, long at) {} + + private final Env env; + private final Map entries = new ConcurrentHashMap<>(); + private final Map inFlight = new ConcurrentHashMap<>(); + + public PlayerCache(Env env) { this.env = env; } + + /** The last answer for this player, or null if none yet. */ + public JsonObject info(UUID uuid) { + Entry e = entries.get(uuid); + return e == null ? null : e.info(); + } + + public void forget(UUID uuid) { entries.remove(uuid); inFlight.remove(uuid); } + + /** Fetch now (unless a fetch is already running); {@code done} runs on the server thread with the new info or null. */ + public void refresh(CorePlayer player, java.util.function.Consumer done) { + if (inFlight.putIfAbsent(player.uuid(), env.clock.getAsLong()) != null) return; + JsonObject body = new JsonObject(); + body.addProperty("uuid", player.uuid().toString()); + env.io(() -> env.panel.call("player/info", body).getAsJsonObject(), info -> { + inFlight.remove(player.uuid()); + if (info.has("exists") && !info.get("exists").getAsBoolean()) { if (done != null) done.accept(null); return; } + entries.put(player.uuid(), new Entry(info, env.clock.getAsLong())); + if (done != null) done.accept(info); + }, e -> { + inFlight.remove(player.uuid()); + if (done != null) done.accept(null); + }); + } + + /** Keep everyone online fresh. Call about once a second on the server thread. */ + public void tick(java.util.function.Consumer refreshed) { + long now = env.clock.getAsLong(); + for (CorePlayer p : env.platform.online()) { + Entry e = entries.get(p.uuid()); + if (e == null || now - e.at() >= REFRESH_MS) refresh(p, info -> { if (info != null && refreshed != null) refreshed.accept(p); }); + } + entries.keySet().removeIf(id -> env.platform.player(id).isEmpty()); + } + + // ---- small accessors used by placeholders and the client link ----------------------------- + + 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) { + 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) { + JsonElement e = o == null ? null : o.get(key); + return e != null && e.isJsonPrimitive() ? e.getAsString() : fallback; + } + + static JsonArray arr(JsonObject o, String key) { + JsonElement e = o == null ? null : o.get(key); + return e != null && e.isJsonArray() ? e.getAsJsonArray() : new JsonArray(); + } +} diff --git a/integrations/common/src/main/java/net/scopenet/core/Wire.java b/integrations/common/src/main/java/net/scopenet/core/Wire.java new file mode 100644 index 0000000..b8bf704 --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/Wire.java @@ -0,0 +1,38 @@ +package net.scopenet.core; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +/** Encoding of client-link messages: a Minecraft "UTF string" (VarInt byte length, then UTF-8), same as FriendlyByteBuf.writeUtf. */ +public final class Wire { + public static final String S2C = "scopenet:s2c"; + public static final String C2S = "scopenet:c2s"; + public static final int VERSION = 1; + private static final int MAX_BYTES = 32767 * 3; + + private Wire() {} + + public static byte[] encode(String json) { + byte[] body = json.getBytes(StandardCharsets.UTF_8); + if (body.length > MAX_BYTES) throw new IllegalArgumentException("message too large"); + ByteArrayOutputStream out = new ByteArrayOutputStream(body.length + 5); + int v = body.length; + while ((v & ~0x7F) != 0) { out.write((v & 0x7F) | 0x80); v >>>= 7; } + out.write(v); + out.writeBytes(body); + return out.toByteArray(); + } + + public static String decode(byte[] data) { + int length = 0, shift = 0, at = 0; + while (true) { + if (at >= data.length || shift > 28) throw new IllegalArgumentException("bad length"); + int b = data[at++]; + length |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + } + if (length < 0 || length > MAX_BYTES || at + length > data.length) throw new IllegalArgumentException("bad length"); + return new String(data, at, length, StandardCharsets.UTF_8); + } +} diff --git a/integrations/common/src/test/java/net/scopenet/core/LinkTest.java b/integrations/common/src/test/java/net/scopenet/core/LinkTest.java new file mode 100644 index 0000000..18ecdff --- /dev/null +++ b/integrations/common/src/test/java/net/scopenet/core/LinkTest.java @@ -0,0 +1,142 @@ +package net.scopenet.core; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.scopenet.integration.ChunkCheckResult; +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 LinkTest { + static final String INFO = "{\"exists\":true,\"name\":\"Alex\",\"joined\":\"2026-01-02T03:04:05Z\",\"global\":{\"level\":12,\"xp\":5000,\"next_level_xp\":6000,\"current_level_xp\":4800,\"progress_pct\":16.6,\"title\":\"Miner\",\"rank\":3}," + + "\"server\":{\"level\":4,\"xp\":300,\"progress_pct\":50.0,\"rank_name\":\"Initiate\"},\"guild\":{\"id\":\"g\",\"name\":\"Iron\",\"tag\":\"IRON\",\"role\":\"leader\",\"claims\":3}," + + "\"balance\":1234.5,\"playtime_secs\":7300,\"server_playtime_secs\":600,\"kills\":10,\"deaths\":4,\"friends\":6,\"achievements\":9," + + "\"quests\":{\"daily\":{\"total\":3,\"completed\":1,\"claimed\":0},\"weekly\":{\"total\":2,\"completed\":2,\"claimed\":1}},\"rank\":{\"group\":\"vip\",\"display\":\"VIP\",\"prefix\":\"[VIP]\"}}"; + + @TempDir Path dir; + Kit kit; + Kit.Player alex; + PlayerCache cache; + ClientLink link; + final List sent = new ArrayList<>(); + + @BeforeEach void setup() { + kit = new Kit(dir); + alex = kit.platform.add("Alex"); + kit.panel.on("player/info", INFO); + cache = new PlayerCache(kit.env); + link = new ClientLink(kit.env, cache, "SMP", (p, bytes) -> sent.add(JsonParser.parseString(Wire.decode(bytes)).getAsJsonObject())); + } + + byte[] msg(String json) { return Wire.encode(json); } + JsonObject last(String type) { for (int i = sent.size() - 1; i >= 0; i--) if (sent.get(i).get("t").getAsString().equals(type)) return sent.get(i); return null; } + + @Test void wireRoundTripsAndRejectsGarbage() { + String text = "{\"t\":\"x\",\"u\":\"héllo ☃\"}"; + assertEquals(text, Wire.decode(Wire.encode(text))); + assertThrows(IllegalArgumentException.class, () -> Wire.decode(new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff})); + assertThrows(IllegalArgumentException.class, () -> Wire.decode(new byte[]{10, 1, 2})); + assertThrows(IllegalArgumentException.class, () -> Wire.decode(new byte[0])); + // 300-byte message uses a two-byte length. + String big = "x".repeat(300); + assertEquals(302, Wire.encode(big).length); + assertEquals(big, Wire.decode(Wire.encode(big))); + } + + @Test void nothingIsSentBeforeHelloAndGarbageIsIgnored() { + link.tick(); + link.receive(alex, new byte[]{1, 2, 3}); + link.receive(alex, msg("{\"t\":\"market\"}")); + link.receive(alex, msg("[1,2]")); + assertTrue(sent.isEmpty()); + assertFalse(link.hasClient(alex.id)); + } + + @Test void helloSendsFeaturesStateAndClaims() { + kit.panel.claims.put("minecraft:overworld:0:0", new ChunkCheckResult(true, true, "Iron", "IRON")); + kit.panel.claims.put("minecraft:overworld:1:0", new ChunkCheckResult(true, false, "Void", "VOID")); + link.receive(alex, msg("{\"t\":\"hello\",\"mod\":\"0.1\"}")); + JsonObject hello = last("hello"); + assertEquals("SMP", hello.get("server").getAsString()); + assertTrue(hello.getAsJsonObject("features").get("economy").getAsBoolean()); + JsonObject state = last("state"); + assertEquals(12, state.get("level").getAsInt()); + assertEquals(1234.5, state.get("balance").getAsDouble()); + assertEquals("IRON", state.getAsJsonObject("guild").get("tag").getAsString()); + assertEquals(2, state.getAsJsonObject("weekly").get("done").getAsInt()); + assertEquals("VIP", state.get("rank").getAsString()); + JsonObject claims = last("claims"); + assertEquals(81, claims.get("cells").getAsString().length()); + assertEquals('1', claims.get("cells").getAsString().charAt(4 * 9 + 4), "own chunk in the middle"); + assertEquals('2', claims.get("cells").getAsString().charAt(4 * 9 + 5), "someone else's to the east"); + assertEquals("IRON", claims.getAsJsonObject("owner").get("tag").getAsString()); + assertTrue(claims.getAsJsonObject("owner").get("mine").getAsBoolean()); + } + + @Test void updatesAreOnlySentWhenSomethingChanged() { + link.receive(alex, msg("{\"t\":\"hello\"}")); + int before = sent.size(); + link.tick(); + link.tick(); + assertEquals(before, sent.size(), "no change, no traffic"); + alex.pos = new Pos("minecraft:overworld", 40, 64, 0, 0, 0); // next chunk over + link.tick(); + assertEquals(before + 1, sent.size()); + assertEquals(2, last("claims").get("cx").getAsInt()); + link.disconnected(alex.id); + alex.pos = new Pos("minecraft:overworld", 400, 64, 0, 0, 0); + link.tick(); + assertEquals(before + 1, sent.size(), "disconnected players get nothing"); + } + + @Test void stateRefreshesFromThePanelOnTheCacheClock() { + link.receive(alex, msg("{\"t\":\"hello\"}")); + kit.panel.on("player/info", INFO.replace("\"balance\":1234.5", "\"balance\":99.0")); + kit.now.addAndGet(PlayerCache.REFRESH_MS + 1); + cache.tick(p -> link.pushState(p)); + assertEquals(99.0, last("state").get("balance").getAsDouble()); + } + + @Test void marketAndShopRequests() { + link.receive(alex, msg("{\"t\":\"hello\"}")); + kit.panel.on("economy/market", "[{\"id\":7,\"seller_name\":\"Mia\",\"item_id\":\"DIAMOND\",\"item_name\":\"Diamond\",\"amount\":3,\"price\":99.5}]"); + link.receive(alex, msg("{\"t\":\"market\"}")); + assertEquals(7, last("market").getAsJsonArray("listings").get(0).getAsJsonObject().get("id").getAsInt()); + alex.held = new Item("DIAMOND", "Diamond", 4, ""); + link.receive(alex, msg("{\"t\":\"shop\"}")); + JsonObject shop = last("shop"); + assertTrue(shop.getAsJsonArray("items").size() > 10); + assertEquals(35.0, shop.getAsJsonObject("held").get("unit").getAsDouble()); + } + + @Test void disabledClientLinkIsSilent() { + kit.env.features = new Features(true, true, true, true, true, false, "$"); + link.receive(alex, msg("{\"t\":\"hello\"}")); + assertTrue(sent.isEmpty()); + } + + @Test void placeholdersMatchThePaperNames() { + Placeholders ph = new Placeholders(kit.env, cache); + assertEquals("1", ph.resolve(alex.id, "level").orElseThrow(), "before the first answer"); + assertEquals("0.00", ph.resolve(alex.id, "balance").orElseThrow()); + cache.refresh(alex, null); + Map expected = new LinkedHashMap<>(); + expected.put("level", "12"); expected.put("xp", "5000"); expected.put("level_progress", "17"); expected.put("title", "Miner"); + expected.put("leaderboard_rank", "3"); expected.put("server_level", "4"); expected.put("server_rank", "Initiate"); + expected.put("guild", "Iron"); expected.put("guild_tag", "IRON"); expected.put("guild_role", "leader"); expected.put("guild_claims", "3"); expected.put("has_guild", "true"); + expected.put("balance", "1234.50"); expected.put("balance_formatted", "$1,234.50"); expected.put("playtime", "2h 1m"); expected.put("playtime_seconds", "7300"); + expected.put("playtime_hours", "2"); expected.put("server_playtime", "10m"); expected.put("quest_progress", "3/5"); expected.put("quest_daily", "1/3"); + expected.put("quest_weekly", "2/2"); expected.put("kills", "10"); expected.put("deaths", "4"); expected.put("kdr", "2.50"); expected.put("friends", "6"); + expected.put("achievements", "9"); expected.put("joined", "2026-01-02"); expected.put("rank", "vip"); expected.put("rank_prefix", "[VIP]"); + expected.forEach((k, v) -> assertEquals(v, ph.resolve(alex.id, k).orElseThrow(), k)); + assertEquals("§a■■§7■■■■■■■■", ph.resolve(alex.id, "level_bar").orElseThrow()); + assertTrue(ph.resolve(alex.id, "nonsense").isEmpty()); + for (String k : Placeholders.KEYS) assertTrue(ph.resolve(alex.id, k).isPresent(), k); + assertEquals("NO", "NO".toUpperCase()); + } +}