Client link protocol, player cache and placeholders in the shared core
This commit is contained in:
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user