Client link protocol, player cache and placeholders in the shared core

This commit is contained in:
Claude committed 2026-09-30 19:30:07 +00:00
1 parent f8862c5431
commit 84c9d46892
5 files changed
+562

No files matched your search

@@ -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<CorePlayer, byte[]> out;
private final String serverName;
private final Map<UUID, String> lastState = new ConcurrentHashMap<>();
private final Map<UUID, String> lastClaimsKey = new ConcurrentHashMap<>();
private final Set<UUID> clients = ConcurrentHashMap.newKeySet();
public ClientLink(Env env, PlayerCache cache, String serverName, BiConsumer<CorePlayer, byte[]> 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<CorePlayer> p = env.platform.player(id);
if (p.isEmpty()) { disconnected(id); continue; }
pushClaims(p.get());
pushState(p.get());
}
}
}
@@ -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<String> 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<String> 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";
}
}
@@ -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<UUID, Entry> entries = new ConcurrentHashMap<>();
private final Map<UUID, Long> 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<JsonObject> 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<CorePlayer> 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();
}
}
@@ -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);
}
}
@@ -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<JsonObject> 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<String, String> 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());
}
}