Merge remote work into the SCOPENET Map branch (livemap/Vantage stay removed)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-10-01 00:32:34 +00:00
commit 30b1d92437
75 files changed
+1473 -2043

No files matched your search

@@ -36,7 +36,9 @@ public record ScopenetPlayer(
QuestProgress weeklyQuests,
String joined,
String rankGroup,
String rankPrefix) {
String rankDisplay,
String rankPrefix,
String rankSuffix) {
public boolean inGuild() { return guild != null; }
+6 -1
View File
@@ -4,8 +4,13 @@
repositories {
maven { url = 'https://repo.bluecolored.de/releases' }
}
configurations.compileClasspath {
attributes {
attribute(org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 21)
}
}
dependencies {
implementation project(':common')
compileOnly 'de.bluecolored.bluemap:BlueMapAPI:2.7.4'
compileOnly 'de.bluecolored:bluemap-api:2.7.4'
implementation 'com.google.code.gson:gson:2.10.1'
}
+1 -1
View File
@@ -1,6 +1,6 @@
allprojects {
group = 'net.scopenet'
version = providers.gradleProperty('releaseVersion').getOrElse('0.4.0')
version = providers.gradleProperty('releaseVersion').getOrElse('0.5.0')
repositories { mavenCentral() }
}
subprojects {
@@ -144,13 +144,10 @@ public final class Integration implements AutoCloseable {
this.notificationHandler = handler;
}
/**
* Start mirroring this server's world and player positions to the panel's
* central live map. {@code worldRoot} is the level folder (e.g. {@code world}).
*/
public void enableLiveMap(java.nio.file.Path worldRoot) {
/** Start sending player positions for the in-app BlueMap territory view. */
public void enableLiveMap() {
if (closed || liveMap != null) return;
liveMap = new LiveMapSync(client, worldRoot, log);
liveMap = new LiveMapSync(client);
liveMap.start();
}
@@ -160,12 +157,6 @@ public final class Integration implements AutoCloseable {
if (map != null) map.offerPlayers(players);
}
/** The world was just saved: pick up its changed chunks soon. */
public void liveMapSaved() {
LiveMapSync map = liveMap;
if (map != null) map.requestScan();
}
public ChunkCheckResult checkChunk(String dimension, int chunkX, int chunkZ, UUID uuid) {
return client.checkChunk(dimension, chunkX, chunkZ, uuid);
}
@@ -1,363 +1,70 @@
package net.scopenet.integration;
import com.google.gson.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Mirrors the world to the panel's central Live Map: changed chunks (raw,
* already-compressed Anvil payloads read straight from the region files) over
* HTTPS, and live player positions over a WebSocket (HTTPS as fallback).
* Nothing here touches the server thread except {@link #offerPlayers}.
*/
/** Sends live player positions to the panel for the in-app BlueMap territory view. */
public final class LiveMapSync implements AutoCloseable {
public record Player(String uuid, String name, String dimension, double x, double y, double z, float yaw, float pitch) {}
private static final String[] DIMS = {"overworld", "the_nether", "the_end"};
private static final int SECTOR = 4096;
private static final int BATCH_BYTES = 6 * 1024 * 1024;
private static final int SCAN_BUDGET_BYTES = 48 * 1024 * 1024;
private final PanelClient client;
private final Path root;
private final Consumer<String> log;
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-livemap");
t.setDaemon(true);
return t;
private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(task, "scopenet-positions");
thread.setDaemon(true);
return thread;
});
/** Per region, the timestamp the panel already holds for each of its 1024 chunks. */
private final Map<String, int[]> known = new HashMap<>();
private final Set<String> manifestLoaded = new HashSet<>();
private final AtomicBoolean scanQueued = new AtomicBoolean();
private volatile boolean panelEnabled;
private volatile boolean closed;
private volatile List<Player> players = List.of();
private volatile boolean playersChanged;
private volatile WebSocket socket;
private volatile long socketRetryAt;
private long nextScan;
private long configCheckAt;
private long levelModified = -1;
private long lastPlayersSent;
private long chunkIntervalSecs = 20;
private boolean warnedMissing;
private volatile boolean changed;
private volatile boolean closed;
private long lastSent;
public LiveMapSync(PanelClient client, Path root, Consumer<String> log) {
this.client = client;
this.root = root;
this.log = log;
}
public LiveMapSync(PanelClient client) { this.client = client; }
public void start() {
worker.scheduleWithFixedDelay(this::scanSafely, 10, 5, TimeUnit.SECONDS);
worker.scheduleWithFixedDelay(this::sendPlayersSafely, 5, 1, TimeUnit.SECONDS);
}
public void start() { worker.scheduleWithFixedDelay(this::sendSafely, 1, 1, TimeUnit.SECONDS); }
public void offerPlayers(List<Player> next) {
if (!next.equals(players)) {
players = List.copyOf(next);
playersChanged = true;
changed = true;
}
}
/** A save just happened; scan soon instead of waiting for the next interval. */
public void requestScan() {
if (closed || !scanQueued.compareAndSet(false, true)) return;
private void sendSafely() {
if (closed) return;
long now = System.nanoTime();
if (!changed && now - lastSent < TimeUnit.SECONDS.toNanos(10)) return;
JsonArray list = new JsonArray();
for (Player p : players) {
JsonObject entry = new JsonObject();
entry.addProperty("uuid", p.uuid());
entry.addProperty("name", p.name());
entry.addProperty("dimension", p.dimension());
entry.addProperty("x", p.x());
entry.addProperty("y", p.y());
entry.addProperty("z", p.z());
entry.addProperty("yaw", p.yaw());
entry.addProperty("pitch", p.pitch());
list.add(entry);
}
JsonObject body = new JsonObject();
body.add("players", list);
try {
worker.schedule(() -> {
scanQueued.set(false);
nextScan = 0;
scanSafely();
}, 3, TimeUnit.SECONDS);
} catch (RejectedExecutionException e) {
scanQueued.set(false);
client.post("livemap/players", body);
changed = false;
lastSent = now;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception ignored) {
// Keep the latest snapshot and retry on the next interval.
}
}
@Override public void close() {
closed = true;
WebSocket ws = socket;
if (ws != null) ws.abort();
worker.shutdownNow();
}
// ---- chunks ----
private void scanSafely() {
if (closed || System.nanoTime() < nextScan) return;
try {
if (!refreshEnabled()) return;
long uploaded = scan();
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(uploaded >= SCAN_BUDGET_BYTES ? 1 : chunkIntervalSecs);
} catch (PanelClient.HttpFailure e) {
if (e.status == 403) panelEnabled = false;
else log.accept("SCOPENET live map: the panel refused an upload (" + e.getMessage() + ")");
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.accept("SCOPENET live map sync failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
nextScan = System.nanoTime() + TimeUnit.SECONDS.toNanos(60);
}
}
/** True when the panel wants this server's map. Rechecks once a minute while it doesn't. */
private boolean refreshEnabled() throws IOException, InterruptedException {
if (panelEnabled) return true;
long now = System.nanoTime();
if (now < configCheckAt) return false;
configCheckAt = now + TimeUnit.SECONDS.toNanos(60);
try {
JsonObject config = client.get("livemap/config");
panelEnabled = config.has("enabled") && config.get("enabled").getAsBoolean();
if (config.has("chunk_interval_secs")) chunkIntervalSecs = Math.max(5, config.get("chunk_interval_secs").getAsLong());
if (panelEnabled) log.accept("SCOPENET live map is on: sending this world to the panel.");
} catch (PanelClient.HttpFailure e) {
panelEnabled = false; // an older panel without live map support
}
return panelEnabled;
}
/** Returns how many bytes of chunk data were uploaded. */
private long scan() throws IOException, InterruptedException {
if (!Files.isDirectory(root)) {
if (!warnedMissing) {
warnedMissing = true;
log.accept("SCOPENET live map: world folder " + root + " not found");
}
return 0;
}
uploadLevelIfChanged();
long total = 0;
for (String dim : DIMS) {
Path dir = regionDir(dim);
if (dir == null) continue;
loadManifest(dim);
Batch batch = new Batch(dim);
List<Path> files = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "r.*.*.mca")) {
stream.forEach(files::add);
}
files.sort(Comparator.comparing(Path::toString));
for (Path file : files) {
if (closed || total + batch.size >= SCAN_BUDGET_BYTES) break;
total += readRegion(dim, file, batch);
}
total += batch.flush();
if (total >= SCAN_BUDGET_BYTES) break;
}
return total;
}
private Path regionDir(String dim) {
String name = root.getFileName() == null ? "world" : root.getFileName().toString();
Path parent = root.toAbsolutePath().getParent();
List<Path> candidates = switch (dim) {
case "the_nether" -> Arrays.asList(root.resolve("DIM-1/region"), sibling(parent, name + "_nether", "DIM-1/region"),
root.resolve("dimensions/minecraft/the_nether/region"));
case "the_end" -> Arrays.asList(root.resolve("DIM1/region"), sibling(parent, name + "_the_end", "DIM1/region"),
root.resolve("dimensions/minecraft/the_end/region"));
default -> List.of(root.resolve("region"), root.resolve("dimensions/minecraft/overworld/region"));
};
for (Path p : candidates) if (p != null && Files.isDirectory(p)) return p;
return null;
}
private static Path sibling(Path parent, String name, String rel) {
return parent == null ? null : parent.resolve(name).resolve(rel);
}
private void loadManifest(String dim) throws IOException, InterruptedException {
if (manifestLoaded.contains(dim)) return;
JsonObject manifest = client.get("livemap/manifest?dim=" + dim);
for (JsonElement el : manifest.getAsJsonArray("regions")) {
JsonObject region = el.getAsJsonObject();
byte[] raw = Base64.getDecoder().decode(region.get("timestamps").getAsString());
int[] ts = new int[1024];
ByteBuffer.wrap(raw).asIntBuffer().get(ts, 0, Math.min(1024, raw.length / 4));
known.put(key(dim, region.get("x").getAsInt(), region.get("z").getAsInt()), ts);
}
manifestLoaded.add(dim);
}
private static String key(String dim, int rx, int rz) {
return dim + ":" + rx + ":" + rz;
}
/** Adds every newer chunk of one region file to the batch; returns bytes uploaded by flushes. */
private long readRegion(String dim, Path file, Batch batch) throws IOException, InterruptedException {
String[] parts = file.getFileName().toString().split("\\.");
int rx, rz;
try {
rx = Integer.parseInt(parts[1]);
rz = Integer.parseInt(parts[2]);
} catch (RuntimeException e) {
return 0;
}
long flushed = 0;
int[] have = known.getOrDefault(key(dim, rx, rz), new int[1024]);
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
long size = ch.size();
if (size < 2L * SECTOR) return 0;
ByteBuffer header = ByteBuffer.allocate(2 * SECTOR);
while (header.hasRemaining()) if (ch.read(header, header.position()) < 0) return 0;
for (int i = 0; i < 1024; i++) {
int loc = header.getInt(i * 4);
int ts = header.getInt(SECTOR + i * 4);
int count = loc & 0xff;
long offset = (loc >>> 8) & 0xffffffL;
if (count == 0 || Integer.toUnsignedLong(ts) <= Integer.toUnsignedLong(have[i])) continue;
if (offset < 2 || (offset + count) * SECTOR > size) continue;
ByteBuffer prefix = ByteBuffer.allocate(5);
while (prefix.hasRemaining()) if (ch.read(prefix, offset * SECTOR + prefix.position()) < 0) break;
if (prefix.hasRemaining()) continue;
int length = prefix.getInt(0);
int compression = prefix.get(4) & 0xff;
// Oversized chunks live in external .mcc files; skip those and anything malformed.
if (length < 2 || length - 1 > count * SECTOR - 5 || (compression & 0x80) != 0 || compression == 0 || compression > 4) continue;
ByteBuffer data = ByteBuffer.allocate(length - 1);
while (data.hasRemaining()) if (ch.read(data, offset * SECTOR + 5 + data.position()) < 0) break;
if (data.hasRemaining()) continue;
batch.add(rx, rz, i, rx * 32 + (i & 31), rz * 32 + (i >> 5), ts, compression, data.array());
if (batch.size >= BATCH_BYTES) flushed += batch.flush();
}
}
return flushed;
}
/** Chunk records waiting to be uploaded; a chunk counts as sent only once the panel accepted it. */
private final class Batch {
final String dim;
final ByteArrayOutputStream out = new ByteArrayOutputStream();
final List<int[]> marks = new ArrayList<>();
int size;
Batch(String dim) {
this.dim = dim;
}
void add(int rx, int rz, int index, int x, int z, int ts, int compression, byte[] data) {
ByteBuffer rec = ByteBuffer.allocate(17 + data.length);
rec.putInt(x).putInt(z).putInt(ts).put((byte) compression).putInt(data.length).put(data);
out.write(rec.array(), 0, rec.capacity());
marks.add(new int[]{rx, rz, index, ts});
size += rec.capacity();
}
long flush() throws IOException, InterruptedException {
if (marks.isEmpty()) return 0;
long sent = size;
try {
client.postBytes("livemap/chunks?dim=" + dim, out.toByteArray());
for (int[] m : marks) known.computeIfAbsent(key(dim, m[0], m[1]), k -> new int[1024])[m[2]] = m[3];
} finally {
out.reset();
marks.clear();
size = 0;
}
return sent;
}
}
private void uploadLevelIfChanged() throws IOException, InterruptedException {
Path level = root.resolve("level.dat");
if (!Files.isRegularFile(level)) return;
long modified = Files.getLastModifiedTime(level).toMillis();
if (modified == levelModified) return;
byte[] bytes = Files.readAllBytes(level);
if (bytes.length > 2 * 1024 * 1024) return;
client.postBytes("livemap/level", bytes);
levelModified = modified;
}
// ---- players ----
private void sendPlayersSafely() {
if (closed || !panelEnabled) return;
long now = System.nanoTime();
// Resend unchanged rosters now and then so the panel knows we're alive.
boolean heartbeat = now - lastPlayersSent > TimeUnit.SECONDS.toNanos(10);
if (!playersChanged && !heartbeat) return;
playersChanged = false;
lastPlayersSent = now;
JsonArray list = new JsonArray();
for (Player p : players) {
JsonObject o = new JsonObject();
o.addProperty("uuid", p.uuid());
o.addProperty("name", p.name());
o.addProperty("dimension", p.dimension());
o.addProperty("x", p.x());
o.addProperty("y", p.y());
o.addProperty("z", p.z());
o.addProperty("yaw", p.yaw());
o.addProperty("pitch", p.pitch());
list.add(o);
}
JsonObject body = new JsonObject();
body.addProperty("type", "players");
body.add("players", list);
try {
WebSocket ws = socket;
if (ws != null && !ws.isOutputClosed()) {
ws.sendText(body.toString(), true).get(5, TimeUnit.SECONDS);
return;
}
connectSocket();
client.post("livemap/players", body);
} catch (PanelClient.HttpFailure e) {
if (e.status == 403) panelEnabled = false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
socket = null; // reconnect later; HTTPS covers the gap
}
}
private void connectSocket() {
long now = System.nanoTime();
if (now < socketRetryAt) return;
socketRetryAt = now + TimeUnit.SECONDS.toNanos(30);
try {
Settings settings = client.settings();
URI panel = settings.panel();
String scheme = "https".equals(panel.getScheme()) ? "wss" : "ws";
String path = panel.getRawPath() == null ? "" : panel.getRawPath();
URI uri = URI.create(scheme + "://" + panel.getRawAuthority() + path + "/api/server/v1/livemap/ws");
client.http().newWebSocketBuilder()
.header("Authorization", "Bearer " + settings.token())
.connectTimeout(Duration.ofSeconds(5))
.buildAsync(uri, new WebSocket.Listener() {
@Override public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
if (data.toString().contains("\"disabled\"")) panelEnabled = false;
ws.request(1);
return null;
}
@Override public CompletionStage<?> onClose(WebSocket ws, int code, String reason) {
if (socket == ws) socket = null;
return null;
}
@Override public void onError(WebSocket ws, Throwable error) {
if (socket == ws) socket = null;
}
}).thenAccept(ws -> {
if (closed) ws.abort();
else socket = ws;
});
} catch (RuntimeException ignored) {
// The HTTPS fallback keeps working.
}
}
}
@@ -33,24 +33,13 @@ public final class Bridge {
}));
integration.startClaimSync();
Path config = Path.of("config/scopenet.properties");
if (Settings.liveMapEnabled(config)) integration.enableLiveMap(worldFolder());
if (Settings.liveMapEnabled(config)) integration.enableLiveMap();
} catch (Exception e) {
// Throwing aborts startup; silently disabling an auth mod is unsafe.
throw new IllegalStateException("SCOPENET cannot start: " + e.getMessage(), e);
}
}
/** The level folder: `level-name` from server.properties, like the game itself reads it. */
private static Path worldFolder() {
Properties props = new Properties();
try (var in = java.nio.file.Files.newBufferedReader(Path.of("server.properties"))) {
props.load(in);
} catch (java.io.IOException ignored) {
// Fall back to the default level name.
}
return Path.of(props.getProperty("level-name", "world"));
}
public static CompletableFuture<String> login(UUID uuid, String name, SocketAddress address) {
Integration current = integration;
if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE);
+1 -1
View File
@@ -24,6 +24,6 @@ jar {
from project(':common').sourceSets.main.output
from project(':api').sourceSets.main.output
from project(':bluemap').sourceSets.main.output
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') }.collect { zipTree(it) } }
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') && !it.name.startsWith('api-') && !it.name.startsWith('bluemap-') }.collect { zipTree(it) } }
exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA'
}
@@ -0,0 +1,57 @@
package net.scopenet.paper;
import net.scopenet.api.ScopenetPlayer;
import net.scopenet.paper.compat.ScopenetApiImpl;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Formats player chat from the cached SCOPENET profile without a network call on the chat thread. */
public final class ChatFormatter {
private static final Pattern MARKDOWN = Pattern.compile("(\\*\\*(.+?)\\*\\*|__(.+?)__|~~(.+?)~~|`(.+?)`|\\*(.+?)\\*)");
private static final Pattern HEX = Pattern.compile("&#([0-9a-fA-F]{6})");
private final ScopenetApiImpl profiles;
public ChatFormatter(ScopenetApiImpl profiles) { this.profiles = profiles; }
private static String clean(String value) {
return value == null ? "" : ChatColor.stripColor(value).replace('%', ' ').replace('[', ' ').replace(']', ' ').trim();
}
public String format(Player player) {
ScopenetPlayer p = profiles.peek(player.getUniqueId()).orElse(null);
String guild = p == null || p.guild() == null ? "" : "§3[" + clean(p.guild().tag()) + "] ";
String title = p == null || p.rankTitle() == null || p.rankTitle().isBlank() ? "Novice" : clean(p.rankTitle());
String group = p == null || p.rankGroup() == null || p.rankGroup().isBlank() ? "Member"
: p.rankDisplay() == null || p.rankDisplay().isBlank() ? clean(p.rankGroup()) : clean(p.rankDisplay());
String prefix = p == null ? "" : p.rankPrefix();
String suffix = p == null ? "" : p.rankSuffix();
String decorated = prefix == null || prefix.isBlank() ? "" : ChatColor.translateAlternateColorCodes('&', prefix.replace("%", "%%")) + " ";
String name = player.getDisplayName().replace("%", "%%");
String tail = suffix == null || suffix.isBlank() ? "" : " " + ChatColor.translateAlternateColorCodes('&', suffix.replace("%", "%%"));
return guild + "§6[" + title + "] §b[" + group + "] §r" + decorated + name + tail + "§7: §f%2$s";
}
public String message(Player player, String raw) {
String input = player.hasPermission("scopenet.chat.color") ? ChatColor.translateAlternateColorCodes('&', raw) : ChatColor.stripColor(raw);
if (player.hasPermission("scopenet.chat.color")) {
Matcher hex = HEX.matcher(input);
StringBuffer colored = new StringBuffer();
while (hex.find()) hex.appendReplacement(colored, Matcher.quoteReplacement(net.md_5.bungee.api.ChatColor.of('#' + hex.group(1)).toString()));
hex.appendTail(colored);
input = colored.toString();
}
Matcher matcher = MARKDOWN.matcher(input);
StringBuffer out = new StringBuffer();
while (matcher.find()) {
String code = matcher.group(2) != null ? "§l" : matcher.group(3) != null ? "§n" : matcher.group(4) != null ? "§m" : matcher.group(5) != null ? "§7" : "§o";
String text = matcher.group(2) != null ? matcher.group(2) : matcher.group(3) != null ? matcher.group(3)
: matcher.group(4) != null ? matcher.group(4) : matcher.group(5) != null ? matcher.group(5) : matcher.group(6);
matcher.appendReplacement(out, Matcher.quoteReplacement(code + text + "§r§f"));
}
matcher.appendTail(out);
return out.toString();
}
}
@@ -27,6 +27,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
private ClientLinkBridge clientLink;
private EssentialsHandler essentialsHandler;
private GuildHandler guildHandler;
private ChatFormatter chatFormatter;
@Override public void onEnable() {
// Install the deny-by-default listener even if configuration is invalid.
@@ -50,15 +51,16 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
// Claim protection runs from a local copy of the panel's claims, refreshed in the background.
integration.startClaimSync();
// Central Live Map: mirror chunks and player positions to the panel.
// Live positions center the in-app BlueMap claim view on the player.
if (getConfig().getBoolean("livemap.enabled", true)) {
integration.enableLiveMap(Bukkit.getWorlds().get(0).getWorldFolder().toPath());
integration.enableLiveMap();
Bukkit.getScheduler().runTaskTimer(this, this::snapshotPlayers, 40, 20);
}
// Developer API: other plugins use SCOPENET through ScopenetApiProvider / the services manager,
// and receive level-ups, achievements and guild changes as ordinary Bukkit events.
api = new ScopenetApiImpl(integration.client());
chatFormatter = new ChatFormatter(api);
ScopenetApiProvider.register(api);
getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal);
// Optional client mod (HUD, claim overlay, shop and market windows).
@@ -82,9 +84,16 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
EssentialsHandler essentials = new EssentialsHandler(this);
essentialsHandler = essentials;
getServer().getPluginManager().registerEvents(essentials, this);
String[] essCmds = {"spawn", "home", "sethome", "delhome", "back", "tpa", "tpaccept", "tpdeny", "rtp", "warp", "playtime"};
String[] essCmds = {"spawn", "home", "sethome", "delhome", "back", "tpa", "tpaccept", "tpdeny", "rtp", "warp", "reqwarp", "warprequests", "warpapprove", "warpdeny", "setwarp", "delwarp", "playtime"};
for (String c : essCmds) bindCommand(c, essentials);
}
HandHandler hand = new HandHandler(this);
getServer().getPluginManager().registerEvents(hand, this);
bindCommand("hand", hand);
bindCommand("handview", hand);
NicknameHandler nick = new NicknameHandler(this);
getServer().getPluginManager().registerEvents(nick, this);
bindCommand("nick", nick);
// Register Economy commands & GUI
{
@@ -102,6 +111,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
bindCommand("guild", guilds);
bindCommand("claim", guilds);
bindCommand("unclaim", guilds);
bindCommand("autoclaim", guilds);
}
// Physical shops, actions from the map, and (when BlueMap is installed) claims, pins and the player panel on it.
@@ -118,7 +128,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
getLogger().info(" - Essentials: " + (settings.essentialsEnabled() ? "ENABLED" : "DISABLED"));
getLogger().info(" - Economy: " + (settings.economyEnabled() ? "ENABLED" : "DISABLED"));
} catch (Exception e) {
getLogger().severe("SCOPENET is blocking joins: " + e.getMessage());
getLogger().log(java.util.logging.Level.SEVERE, "SCOPENET is blocking joins", e);
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer(Integration.UNAVAILABLE);
}
}
@@ -166,11 +176,6 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
integration.liveMapPlayers(list);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void worldSaved(org.bukkit.event.world.WorldSaveEvent event) {
if (integration != null) integration.liveMapSaved();
}
public static String dimension(World world) {
return world.getKey().toString();
}
@@ -357,8 +362,12 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
if (claimed(event.getBlock())) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void chat(AsyncPlayerChatEvent event) {
if (integration != null && integration.settings().socialEnabled() && integration.settings().chatPrefixesEnabled() && chatFormatter != null) {
event.setFormat(chatFormatter.format(event.getPlayer()));
event.setMessage(chatFormatter.message(event.getPlayer(), event.getMessage()));
}
add(event.getPlayer(), "messages");
}
@@ -27,8 +27,10 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
private final ScopenetPlugin plugin;
private final File homesFile;
private final File warpsFile;
private final File warpRequestsFile;
private final YamlConfiguration homesConfig;
private final YamlConfiguration warpsConfig;
private final YamlConfiguration warpRequestsConfig;
private final Map<UUID, Map<String, Location>> homes = new ConcurrentHashMap<>();
private final Map<String, Location> warps = new ConcurrentHashMap<>();
@@ -43,8 +45,10 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
this.plugin = plugin;
this.homesFile = new File(plugin.getDataFolder(), "homes.yml");
this.warpsFile = new File(plugin.getDataFolder(), "warps.yml");
this.warpRequestsFile = new File(plugin.getDataFolder(), "warp_requests.yml");
this.homesConfig = YamlConfiguration.loadConfiguration(homesFile);
this.warpsConfig = YamlConfiguration.loadConfiguration(warpsFile);
this.warpRequestsConfig = YamlConfiguration.loadConfiguration(warpRequestsFile);
loadHomes();
loadWarps();
}
@@ -100,6 +104,11 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
}
}
private void saveWarpRequests() {
try { warpRequestsConfig.save(warpRequestsFile); }
catch (IOException e) { plugin.getLogger().warning("Failed to save warp requests: " + e.getMessage()); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTeleport(PlayerTeleportEvent event) {
if (event.getFrom() != null && event.getCause() != PlayerTeleportEvent.TeleportCause.UNKNOWN) {
@@ -160,6 +169,12 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
case "tpdeny" -> handleTpDeny(player);
case "rtp" -> handleRtp(player);
case "warp" -> handleWarp(player, args);
case "reqwarp" -> requestWarp(player, args);
case "warprequests" -> listWarpRequests(player);
case "warpapprove" -> reviewWarp(player, args, true);
case "warpdeny" -> reviewWarp(player, args, false);
case "setwarp" -> setWarp(player, args);
case "delwarp" -> deleteWarp(player, args);
case "playtime" -> handlePlaytime(player, args);
default -> { return false; }
}
@@ -389,6 +404,88 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
player.sendMessage(ChatColor.GREEN + "Teleported to warp " + ChatColor.YELLOW + warpName + ChatColor.GREEN + ".");
}
private String warpName(Player player, String[] args, String usage) {
if (args.length != 1 || !args[0].matches("[A-Za-z0-9_-]{2,24}")) {
player.sendMessage(ChatColor.RED + "Usage: " + usage + " (2–24 letters, numbers, _ or -)");
return null;
}
return args[0].toLowerCase(Locale.ROOT);
}
private void requestWarp(Player player, String[] args) {
String name = warpName(player, args, "/reqwarp <name>");
if (name == null) return;
if (warps.containsKey(name)) { player.sendMessage(ChatColor.RED + "That warp already exists."); return; }
if (warpRequestsConfig.contains(name) && !player.getUniqueId().toString().equals(warpRequestsConfig.getString(name + ".uuid"))) {
player.sendMessage(ChatColor.RED + "That warp name has already been requested."); return;
}
warpRequestsConfig.set(name + ".location", player.getLocation());
warpRequestsConfig.set(name + ".uuid", player.getUniqueId().toString());
warpRequestsConfig.set(name + ".requester", player.getName());
warpRequestsConfig.set(name + ".created_at", System.currentTimeMillis());
saveWarpRequests();
player.sendMessage(ChatColor.GREEN + "Warp request sent. A server admin can approve it with /warpapprove " + name + ".");
Bukkit.getOnlinePlayers().stream().filter(p -> p.hasPermission("scopenet.command.warp.manage"))
.forEach(p -> p.sendMessage(ChatColor.YELLOW + player.getName() + " requested warp '" + name + "'. Use /warprequests to review."));
}
private boolean warpAdmin(Player player) {
if (player.hasPermission("scopenet.command.warp.manage")) return true;
player.sendMessage(ChatColor.RED + "Only server admins can manage warps.");
return false;
}
private void listWarpRequests(Player player) {
if (!warpAdmin(player)) return;
if (warpRequestsConfig.getKeys(false).isEmpty()) { player.sendMessage(ChatColor.YELLOW + "No pending warp requests."); return; }
player.sendMessage(ChatColor.GOLD + "Pending warp requests:");
for (String name : warpRequestsConfig.getKeys(false)) {
Location at = warpRequestsConfig.getLocation(name + ".location");
player.sendMessage(ChatColor.YELLOW + name + ChatColor.GRAY + " by " + warpRequestsConfig.getString(name + ".requester", "unknown")
+ (at == null || at.getWorld() == null ? "" : " at " + at.getWorld().getName() + " " + at.getBlockX() + ", " + at.getBlockY() + ", " + at.getBlockZ()));
}
}
private void reviewWarp(Player player, String[] args, boolean approve) {
if (!warpAdmin(player)) return;
String name = warpName(player, args, approve ? "/warpapprove <name>" : "/warpdeny <name>");
if (name == null) return;
Location at = warpRequestsConfig.getLocation(name + ".location");
if (at == null || at.getWorld() == null) { player.sendMessage(ChatColor.RED + "No pending request with a loaded world has that name."); return; }
if (approve) {
if (warps.containsKey(name)) { player.sendMessage(ChatColor.RED + "Warp already exists. Deny this request or choose another name."); return; }
warps.put(name, at);
saveWarps();
}
String uuid = warpRequestsConfig.getString(name + ".uuid", "");
warpRequestsConfig.set(name, null);
saveWarpRequests();
player.sendMessage(ChatColor.GREEN + "Warp request " + (approve ? "approved" : "denied") + ": " + name);
try {
Player requester = Bukkit.getPlayer(UUID.fromString(uuid));
if (requester != null) requester.sendMessage(ChatColor.YELLOW + "Your warp request '" + name + "' was " + (approve ? "approved" : "denied") + ".");
} catch (IllegalArgumentException ignored) {}
}
private void setWarp(Player player, String[] args) {
if (!warpAdmin(player)) return;
String name = warpName(player, args, "/setwarp <name>");
if (name == null) return;
warps.put(name, player.getLocation());
saveWarps();
player.sendMessage(ChatColor.GREEN + "Warp set: " + name);
}
private void deleteWarp(Player player, String[] args) {
if (!warpAdmin(player)) return;
String name = warpName(player, args, "/delwarp <name>");
if (name == null) return;
if (warps.remove(name) == null) { player.sendMessage(ChatColor.RED + "Warp not found."); return; }
warpsConfig.set(name, null);
saveWarps();
player.sendMessage(ChatColor.GREEN + "Warp deleted: " + name);
}
private void openWarpPicker(Player player) {
Inventory inv = Bukkit.createInventory(null, 27, GUI_WARPS_TITLE);
int slot = 11;
@@ -20,6 +20,8 @@ import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
@@ -34,6 +36,8 @@ public final class GuildHandler implements CommandExecutor, Listener {
private final File guildHomesFile;
private final YamlConfiguration guildHomesConfig;
private final Map<String, Location> guildHomes = new ConcurrentHashMap<>();
private final Set<UUID> autoClaim = ConcurrentHashMap.newKeySet();
private final Map<UUID, String> lastAutoClaim = new ConcurrentHashMap<>();
private static final String GUI_GUILD_TITLE = ChatColor.DARK_AQUA + "Guild Dashboard";
@@ -86,6 +90,23 @@ public final class GuildHandler implements CommandExecutor, Listener {
handleUnclaim(player);
return true;
}
if (cmd.equals("autoclaim")) {
if (!player.hasPermission("scopenet.command.claim")) { player.sendMessage(ChatColor.RED + "You cannot claim land."); return true; }
if (args.length != 1 || (!args[0].equalsIgnoreCase("on") && !args[0].equalsIgnoreCase("off"))) {
player.sendMessage(ChatColor.YELLOW + "Usage: /autoclaim on|off"); return true;
}
if (args[0].equalsIgnoreCase("on")) {
autoClaim.add(player.getUniqueId());
lastAutoClaim.remove(player.getUniqueId());
player.sendMessage(ChatColor.GREEN + "Auto-claim is on. New chunks you walk into will be claimed for your guild.");
claimOnMove(player, player.getLocation());
} else {
autoClaim.remove(player.getUniqueId());
lastAutoClaim.remove(player.getUniqueId());
player.sendMessage(ChatColor.YELLOW + "Auto-claim is off.");
}
return true;
}
if (cmd.equals("guild") || cmd.equals("g")) {
if (args.length == 0 || args[0].equalsIgnoreCase("info") || args[0].equalsIgnoreCase("gui")) {
@@ -211,6 +232,39 @@ public final class GuildHandler implements CommandExecutor, Listener {
});
}
@EventHandler
public void onMove(PlayerMoveEvent event) {
if (event.getTo() == null || !autoClaim.contains(event.getPlayer().getUniqueId())) return;
if (event.getFrom().getWorld() == event.getTo().getWorld()
&& event.getFrom().getChunk().getX() == event.getTo().getChunk().getX()
&& event.getFrom().getChunk().getZ() == event.getTo().getChunk().getZ()) return;
claimOnMove(event.getPlayer(), event.getTo());
}
@EventHandler public void onQuit(PlayerQuitEvent event) {
autoClaim.remove(event.getPlayer().getUniqueId());
lastAutoClaim.remove(event.getPlayer().getUniqueId());
}
private void claimOnMove(Player player, Location location) {
if (!integration.settings().landClaimingEnabled()) return;
int x = location.getBlockX() >> 4, z = location.getBlockZ() >> 4;
String dim = ScopenetPlugin.dimension(location.getWorld());
String key = dim + ':' + x + ':' + z;
if (key.equals(lastAutoClaim.put(player.getUniqueId(), key))) return;
if (integration.isClaimed(dim, x, z)) return;
UUID uuid = player.getUniqueId();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
integration.client().claimChunk(dim, x, z, uuid);
player.sendMessage(ChatColor.GREEN + "Auto-claimed chunk [" + x + ", " + z + "].");
} catch (Exception e) {
autoClaim.remove(uuid);
player.sendMessage(ChatColor.RED + "Auto-claim stopped: " + e.getMessage());
}
});
}
private void handleUnclaim(Player player) {
Chunk chunk = player.getLocation().getChunk();
String dim = ScopenetPlugin.dimension(player.getWorld());
@@ -0,0 +1,71 @@
package net.scopenet.paper.commands;
import net.md_5.bungee.api.chat.ClickEvent;
import net.md_5.bungee.api.chat.ComponentBuilder;
import net.md_5.bungee.api.chat.HoverEvent;
import net.md_5.bungee.api.chat.TextComponent;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/** Share a read-only snapshot of the held item through a clickable chat message. */
public final class HandHandler implements CommandExecutor, Listener {
private record Shared(ItemStack item, long expires) {}
private final Map<String, Shared> shares = new ConcurrentHashMap<>();
private static final String TITLE = ChatColor.DARK_GRAY + "Shared item";
public HandHandler(Plugin plugin) {
Bukkit.getScheduler().runTaskTimer(plugin, () -> shares.entrySet().removeIf(e -> e.getValue().expires < System.currentTimeMillis()), 1200, 1200);
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) { sender.sendMessage("Only players can use this command."); return true; }
if (command.getName().equalsIgnoreCase("handview")) {
if (args.length != 1) return false;
Shared shared = shares.get(args[0]);
if (shared == null || shared.expires < System.currentTimeMillis()) { player.sendMessage(ChatColor.RED + "That item share has expired."); return true; }
Inventory view = Bukkit.createInventory(null, 9, TITLE);
view.setItem(4, shared.item.clone());
player.openInventory(view);
return true;
}
ItemStack item = player.getInventory().getItemInMainHand();
if (item.getType() == Material.AIR) { player.sendMessage(ChatColor.RED + "Hold an item first."); return true; }
String token = UUID.randomUUID().toString().substring(0, 8);
shares.put(token, new Shared(item.clone(), System.currentTimeMillis() + 300_000));
String name = item.hasItemMeta() && item.getItemMeta().hasDisplayName()
? item.getItemMeta().getDisplayName() : item.getType().name().toLowerCase().replace('_', ' ');
TextComponent link = new TextComponent("[" + name + " ×" + item.getAmount() + "]");
link.setColor(net.md_5.bungee.api.ChatColor.AQUA);
link.setClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/handview " + token));
link.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new ComponentBuilder("Click to inspect " + name + " (5 minutes)").create()));
TextComponent line = new TextComponent(player.getDisplayName() + ChatColor.WHITE + " is holding ");
line.addExtra(link);
for (Player target : Bukkit.getOnlinePlayers()) {
target.spigot().sendMessage(line);
}
return true;
}
@EventHandler public void onInventoryClick(InventoryClickEvent event) {
if (TITLE.equals(event.getView().getTitle())) event.setCancelled(true);
}
@EventHandler public void onInventoryDrag(InventoryDragEvent event) {
if (TITLE.equals(event.getView().getTitle())) event.setCancelled(true);
}
}
@@ -0,0 +1,63 @@
package net.scopenet.paper.commands;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.plugin.Plugin;
import java.io.File;
import java.io.IOException;
/** Player-owned chat nicknames, keyed by UUID and restored on every join. */
public final class NicknameHandler implements CommandExecutor, Listener {
private final Plugin plugin;
private final File file;
private final YamlConfiguration config;
public NicknameHandler(Plugin plugin) {
this.plugin = plugin;
this.file = new File(plugin.getDataFolder(), "nicknames.yml");
this.config = YamlConfiguration.loadConfiguration(file);
}
@EventHandler public void join(PlayerJoinEvent event) {
Player player = event.getPlayer();
String saved = config.getString(player.getUniqueId().toString());
if (saved != null && !saved.isBlank()) player.setDisplayName(ChatColor.translateAlternateColorCodes('&', saved));
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) { sender.sendMessage("Only players can set a nickname."); return true; }
if (args.length != 1) { player.sendMessage(ChatColor.YELLOW + "Usage: /nick <name|off>"); return true; }
String value = args[0];
if (value.equalsIgnoreCase("off")) {
config.set(player.getUniqueId().toString(), null);
player.setDisplayName(player.getName());
save();
player.sendMessage(ChatColor.GREEN + "Nickname removed.");
return true;
}
String plain = ChatColor.stripColor(ChatColor.translateAlternateColorCodes('&', value));
if (plain.length() < 2 || plain.length() > 24 || !plain.matches("[A-Za-z0-9_ -]+")) {
player.sendMessage(ChatColor.RED + "Nicknames must be 2–24 letters, numbers, spaces, _ or -.");
return true;
}
if (!player.hasPermission("scopenet.chat.color")) value = plain;
config.set(player.getUniqueId().toString(), value);
player.setDisplayName(ChatColor.translateAlternateColorCodes('&', value));
save();
player.sendMessage(ChatColor.GREEN + "Nickname set to " + player.getDisplayName() + ChatColor.GREEN + ".");
return true;
}
private void save() {
try { config.save(file); }
catch (IOException e) { plugin.getLogger().warning("Failed to save nicknames: " + e.getMessage()); }
}
}
@@ -102,6 +102,7 @@ public final class LuckPermsModule implements IntegrationModule {
JsonObject config = answer.has("config") && answer.get("config").isJsonObject() ? answer.getAsJsonObject("config") : new JsonObject();
if (config.has("mode")) lastMode = config.get("mode").getAsString();
if (context.getBoolean("luckperms", "apply-panel-groups", false) && config.has("assign")) apply(config.getAsJsonArray("assign"));
if (config.has("level_assign")) apply(config.getAsJsonArray("level_assign"));
} catch (Exception e) {
context.log().fine("LuckPerms report failed: " + e.getMessage());
}
@@ -69,7 +69,7 @@ public final class ScopenetApiImpl implements ScopenetApi, AutoCloseable {
guild == null ? null : new GuildSummary(str(guild, "id"), str(guild, "name"), str(guild, "tag"), str(guild, "role"), (int) num(guild, "claims")),
dbl(j, "balance"), num(j, "playtime_secs"), num(j, "server_playtime_secs"), (int) num(j, "kills"), (int) num(j, "deaths"),
(int) num(j, "friends"), (int) num(j, "achievements"), quests(j, "daily"), quests(j, "weekly"),
str(j, "joined"), str(rank, "group"), str(rank, "prefix"));
str(j, "joined"), str(rank, "group"), str(rank, "display"), str(rank, "prefix"), str(rank, "suffix"));
}
static ScopenetGuild guild(JsonObject j) {
@@ -52,9 +52,8 @@ essentials:
rtp-radius: 2500
livemap:
# Send world chunks and live player positions to the panel's central Live Map.
# Players open it from the launcher; you don't run Vantage on this server.
# It also needs "Live Map" switched on for this server in the panel.
# Send live player positions to the panel for the launcher BlueMap claim view.
# Configure BlueMap's public address and enable live positions in the panel.
enabled: true
economy:
@@ -50,10 +50,37 @@ commands:
permission: scopenet.command.warp
description: Teleport to a server warp or open the warps GUI
aliases: [warps]
reqwarp:
permission: scopenet.command.reqwarp
description: Request a new public warp at your location
warprequests:
permission: scopenet.command.warp.manage
description: List pending warp requests
warpapprove:
permission: scopenet.command.warp.manage
description: Approve a pending warp request
warpdeny:
permission: scopenet.command.warp.manage
description: Deny a pending warp request
setwarp:
permission: scopenet.command.warp.manage
description: Set a public warp at your location
delwarp:
permission: scopenet.command.warp.manage
description: Delete a public warp
playtime:
permission: scopenet.command.playtime
description: Check total and session playtime
aliases: [ontime]
hand:
permission: scopenet.command.hand
description: Share your held item in chat
handview:
permission: scopenet.command.hand
description: Inspect a shared item
nick:
permission: scopenet.command.nick
description: Set or remove your chat nickname
balance:
permission: scopenet.command.balance
description: Check your server economy balance
@@ -94,6 +121,9 @@ commands:
unclaim:
permission: scopenet.command.unclaim
description: Unclaim the current chunk
autoclaim:
permission: scopenet.command.claim
description: Claim chunks as you walk into them
permissions:
@@ -137,9 +167,21 @@ permissions:
scopenet.command.warp:
description: Use /warp
default: true
scopenet.command.reqwarp:
description: Request a public warp
default: true
scopenet.command.warp.manage:
description: Review and manage public warps
default: op
scopenet.command.playtime:
description: Use /playtime
default: true
scopenet.command.hand:
description: Share and inspect held items in chat
default: true
scopenet.command.nick:
description: Set or remove your chat nickname
default: true
scopenet.command.balance:
description: Use /balance
default: true
@@ -176,6 +218,9 @@ permissions:
scopenet.command.unclaim:
description: Use /unclaim
default: true
scopenet.chat.color:
description: Use Minecraft color codes in chat
default: op
scopenet.command.scopenet.help:
description: Use /scopenet help
default: true