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:
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; }
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user