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);
}
}