BlueMap addon, shop points, map actions wired into Paper and Fabric; client opens shop windows

This commit is contained in:
Claude committed 2026-09-30 20:24:02 +00:00
1 parent ad7285efac
commit 2817ad2ac5
33 files changed
+754 -13

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
// SCOPENET's BlueMap addon: guild claims, spawn, warps, shops and a player side panel on the 3D map.
// BlueMap's API is the same on Paper, Fabric and Forge, so this one module serves them all. It is only ever called
// when BlueMap is installed, and BlueMap provides these classes at runtime (compileOnly).
repositories {
maven { url = 'https://repo.bluecolored.de/releases' }
}
dependencies {
implementation project(':common')
compileOnly 'de.bluecolored.bluemap:BlueMapAPI:2.7.4'
implementation 'com.google.code.gson:gson:2.10.1'
}
@@ -0,0 +1,211 @@
package net.scopenet.bluemap;
import com.flowpowered.math.vector.Vector2d;
import com.flowpowered.math.vector.Vector3d;
import com.google.gson.JsonObject;
import de.bluecolored.bluemap.api.BlueMapAPI;
import de.bluecolored.bluemap.api.BlueMapMap;
import de.bluecolored.bluemap.api.markers.Marker;
import de.bluecolored.bluemap.api.markers.MarkerSet;
import de.bluecolored.bluemap.api.markers.POIMarker;
import de.bluecolored.bluemap.api.markers.ShapeMarker;
import de.bluecolored.bluemap.api.math.Color;
import de.bluecolored.bluemap.api.math.Shape;
import net.scopenet.core.map.ChunkRegions;
import net.scopenet.core.map.MapData;
import net.scopenet.core.map.MapModel;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.logging.Logger;
/**
* Puts SCOPENET on the BlueMap 3D map:
* <ul>
* <li>guild land as coloured regions, with the guild's icon and name,</li>
* <li>spawn, warps, guild homes (and homes, if the admin allows) as pins,</li>
* <li>markets and shops as pins that list what is for sale,</li>
* <li>a script that opens a side panel when you click a player.</li>
* </ul>
* Start it once from the plugin or mod. It waits for BlueMap to come up, so the load order doesn't matter, and does
* nothing if BlueMap isn't installed.
*/
public final class MapAddon {
/** Where the addon gets its data. Both calls must be safe from any thread. */
public interface Feed {
MapData snapshot();
JsonObject playersJson();
}
static final String WEB_DIR = "assets/scopenet";
static final List<String> ASSETS = List.of("scopenet.js", "scopenet.css", "spawn.svg", "warp.svg", "home.svg", "guild-home.svg", "market.svg", "shop.svg", "guild.svg");
static final String CLAIMS = "scopenet-claims", GUILDS = "scopenet-guilds", PLACES = "scopenet-places", SHOPS = "scopenet-shops";
static final float CLAIM_Y = 66f;
private final Feed feed;
private final Logger log;
private ScheduledExecutorService worker;
private volatile boolean enabled;
private MapAddon(Feed feed, Logger log) { this.feed = feed; this.log = log; }
/** Hook into BlueMap. Returns a handle to stop it. */
public static MapAddon start(Feed feed, Logger log) {
MapAddon addon = new MapAddon(feed, log);
BlueMapAPI.onEnable(addon::enable);
BlueMapAPI.onDisable(api -> addon.disable());
return addon;
}
public void stop() { disable(); }
// ---- lifecycle -------------------------------------------------------------------------
private synchronized void enable(BlueMapAPI api) {
try {
Path root = api.getWebApp().getWebRoot().resolve(WEB_DIR);
Files.createDirectories(root);
for (String name : ASSETS) copy(name, root.resolve(name));
api.getWebApp().registerScript(WEB_DIR + "/scopenet.js");
api.getWebApp().registerStyle(WEB_DIR + "/scopenet.css");
} catch (IOException | RuntimeException e) {
log.warning("SCOPENET could not add its files to BlueMap: " + e);
return;
}
enabled = true;
worker = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "scopenet-bluemap");
t.setDaemon(true);
return t;
});
worker.scheduleWithFixedDelay(() -> refresh(api), 2, 10, TimeUnit.SECONDS);
log.info("SCOPENET is adding guild claims, pins and the player panel to BlueMap.");
}
private synchronized void disable() {
enabled = false;
if (worker != null) worker.shutdownNow();
worker = null;
}
private static void copy(String name, Path target) throws IOException {
try (InputStream in = MapAddon.class.getResourceAsStream("/" + WEB_DIR + "/" + name)) {
if (in == null) throw new IOException("missing resource " + name);
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
}
// ---- refresh ---------------------------------------------------------------------------
private void refresh(BlueMapAPI api) {
if (!enabled) return;
try {
MapData data = feed.snapshot();
writePlayers(api);
for (BlueMapMap map : api.getMaps()) update(map, data);
} catch (RuntimeException | IOException e) {
log.fine("SCOPENET map refresh failed: " + e);
}
}
private void writePlayers(BlueMapAPI api) throws IOException {
Path file = api.getWebApp().getWebRoot().resolve(WEB_DIR).resolve("players.json");
Path tmp = file.resolveSibling("players.json.tmp");
Files.writeString(tmp, feed.playersJson().toString(), StandardCharsets.UTF_8);
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
private void update(BlueMapMap map, MapData data) {
String dimension = MapModel.dimensionOf(map.getId(), map.getName());
set(map, CLAIMS, "Guild claims", claimMarkers(data, dimension));
set(map, GUILDS, "Guild names", guildMarkers(data, dimension));
set(map, PLACES, "Spawn, warps & homes", pinMarkers(data, dimension, false));
set(map, SHOPS, "Markets & shops", pinMarkers(data, dimension, true));
}
/** Replace a marker set's contents in place, so the map doesn't flicker. */
private static void set(BlueMapMap map, String id, String label, Map<String, Marker> wanted) {
MarkerSet set = map.getMarkerSets().computeIfAbsent(id, k -> MarkerSet.builder().label(label).toggleable(true).defaultHidden(false).build());
set.getMarkers().keySet().removeIf(key -> !wanted.containsKey(key));
wanted.forEach(set::put);
}
// ---- markers ---------------------------------------------------------------------------
private static Shape shape(ChunkRegions.Ring ring) {
List<Vector2d> points = new ArrayList<>();
for (ChunkRegions.Point p : ring.points()) points.add(new Vector2d(p.x(), p.z()));
return new Shape(points);
}
private static Map<String, Marker> claimMarkers(MapData data, String dimension) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.ClaimRegion c : data.claims()) {
if (!c.dimension().equals(dimension)) continue;
Shape[] holes = c.holes().stream().map(MapAddon::shape).toArray(Shape[]::new);
ShapeMarker marker = ShapeMarker.builder()
.label(c.name())
.shape(shape(c.outer()), CLAIM_Y)
.holes(holes)
.lineColor(new Color(c.color(), 0.95f))
.fillColor(new Color(c.color(), 0.28f))
.lineWidth(3)
.depthTestEnabled(false)
.detail(c.detail())
.build();
out.put("claim-" + c.id(), marker);
}
return out;
}
/** One icon and name per area, in the middle of it, fading out when you are far away. */
private static Map<String, Marker> guildMarkers(MapData data, String dimension) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.ClaimRegion c : data.claims()) {
if (!c.dimension().equals(dimension)) continue;
POIMarker.Builder builder = POIMarker.builder()
.label(c.name() + " [" + c.tag() + "]")
.position(c.labelX(), CLAIM_Y + 1, c.labelZ())
.styleClasses("scopenet-guild-icon")
.maxDistance(1600)
.detail(c.detail());
builder = c.icon().isEmpty() ? builder.icon(WEB_DIR + "/guild.svg", 16, 16) : builder.icon(c.icon(), 16, 16);
out.put("guild-" + c.id(), builder.build());
}
return out;
}
private static Map<String, Marker> pinMarkers(MapData data, String dimension, boolean shops) {
Map<String, Marker> out = new LinkedHashMap<>();
for (MapData.Pin p : data.pins()) {
boolean isShop = p.kind().equals("market") || p.kind().equals("shop");
if (!p.dimension().equals(dimension) || isShop != shops) continue;
String icon = switch (p.kind()) {
case "spawn" -> "spawn.svg";
case "warp" -> "warp.svg";
case "home" -> "home.svg";
case "guild_home" -> "guild-home.svg";
case "market" -> "market.svg";
default -> "shop.svg";
};
out.put(p.id(), POIMarker.builder()
.label(p.label())
.position(new Vector3d(p.x(), p.y() + 1, p.z()))
.icon(WEB_DIR + "/" + icon, 16, 16)
.styleClasses("scopenet-pin", "scopenet-" + p.kind().replace('_', '-'))
.maxDistance(2400)
.detail(p.detail())
.build());
}
return out;
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#22d3ee" stroke="#ffffff" stroke-width="2"/><path d="M16 6l8 3v6c0 5-3.4 8.6-8 10-4.6-1.4-8-5-8-10V9z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 243 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path d="M16 3l11 4v8c0 7-4.6 12-11 14C9.6 27 5 22 5 15V7z" fill="#8b6cff" stroke="#fff" stroke-width="2"/><path d="M16 9l2.2 4.6 5 .7-3.6 3.5.9 5L16 20.4 11.5 22.8l.9-5-3.6-3.5 5-.7z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 288 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#fbbf24" stroke="#ffffff" stroke-width="2"/><path d="M16 7l9 8h-3v8h-4v-5h-4v5h-4v-8H7z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 229 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#fb7185" stroke="#ffffff" stroke-width="2"/><path d="M7 12l2-5h14l2 5v2c0 1.7-1.3 3-3 3s-3-1.3-3-3c0 1.7-1.3 3-3 3s-3-1.3-3-3c0 1.7-1.3 3-3 3s-3-1.3-3-3zM9 19h14v6H9z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,64 @@
/* SCOPENET on BlueMap: marker styling and the player side panel. Loaded by BlueMap through the addon. */
/* Guild icons sit on the map as small rounded badges, whatever size the uploaded image is. */
.scopenet-guild-icon img,
.bm-marker-poi.scopenet-guild-icon img {
width: 34px !important;
height: 34px !important;
object-fit: cover;
border-radius: 9px;
border: 2px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.55);
background: #1a1d25;
}
.scopenet-pin img,
.bm-marker-poi.scopenet-pin img {
width: 26px !important;
height: 26px !important;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.55));
}
/* Popup text inside marker details. */
.scopenet-detail { font-size: 13px; line-height: 1.45; min-width: 160px; }
.scopenet-detail span { opacity: 0.7; }
.scopenet-detail code { background: rgba(255, 255, 255, 0.08); padding: 1px 5px; border-radius: 4px; }
.scopenet-list { margin: 4px 0 0; padding-left: 16px; }
/* Player panel (shown in a plain browser; inside the SCOPENET launcher the launcher shows its own). */
#scopenet-panel {
position: fixed;
top: 64px;
right: 14px;
width: 300px;
max-height: calc(100vh - 90px);
overflow-y: auto;
z-index: 50;
padding: 16px;
border-radius: 14px;
background: rgba(20, 22, 30, 0.96);
color: #e6e8ee;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 16px 48px -12px rgba(0, 0, 0, 0.75);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 13px;
animation: scopenet-in 0.18s ease;
}
@keyframes scopenet-in { from { opacity: 0; transform: translateX(18px); } }
#scopenet-panel button.close {
position: absolute; top: 8px; right: 8px; width: 26px; height: 26px; border: 0; border-radius: 8px;
background: transparent; color: #9aa0ae; font-size: 18px; cursor: pointer;
}
#scopenet-panel button.close:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }
#scopenet-panel .head { display: flex; gap: 12px; align-items: center; padding-right: 26px; }
#scopenet-panel .head img { width: 56px; height: 56px; border-radius: 16px; border: 3px solid #8b6cff; background: #1a1d25; image-rendering: pixelated; }
#scopenet-panel h3 { margin: 0 0 2px; font-size: 17px; }
#scopenet-panel .title { color: #fbbf24; font-size: 12px; }
#scopenet-panel .chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
#scopenet-panel .chip { padding: 2px 9px; border-radius: 99px; background: rgba(255, 255, 255, 0.08); font-size: 11px; font-weight: 650; }
#scopenet-panel .chip.lv { color: #8b6cff; }
#scopenet-panel .chip.guild { color: #22d3ee; }
#scopenet-panel .chip.rank { color: #fbbf24; }
#scopenet-panel .facts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
#scopenet-panel .facts div { padding: 8px 10px; border-radius: 10px; background: rgba(255, 255, 255, 0.05); color: #9aa0ae; font-size: 11px; }
#scopenet-panel .facts strong { display: block; color: #e6e8ee; font-size: 14px; margin-top: 2px; }
#scopenet-panel .note { margin: 14px 0 0; padding: 10px 12px; border-radius: 10px; background: rgba(139, 108, 255, 0.12); color: #c9bfff; font-size: 12px; line-height: 1.4; }
@@ -0,0 +1,115 @@
/*
* SCOPENET on BlueMap. Registered by the SCOPENET addon, so it runs inside BlueMap's own page: the same in a browser and
* inside the SCOPENET launcher.
*
* - Click a player on the map to see who they are (level, guild, rank, title...), read from players.json that the
* server keeps up to date next to this file.
* - Inside the launcher (this page is in an iframe) the click is handed to the launcher, which shows the full side panel
* with the actions that need a signed-in account: teleport request, message, guild invite.
*/
(function () {
'use strict';
var BASE = 'assets/scopenet/';
var PLAYER_PREFIX = 'bm-marker-bm-player-';
var embedded = window.parent && window.parent !== window;
var players = {};
var selected = null;
function esc(text) {
return String(text == null ? '' : text).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
/** The uuid behind a player marker element, or null. BlueMap ids them "bm-marker-bm-player-<uuid>". */
function uuidFromId(id) {
return typeof id === 'string' && id.indexOf(PLAYER_PREFIX) === 0 ? id.substring(PLAYER_PREFIX.length) : null;
}
/** Which player marker is under this screen point? Works even where the marker ignores pointer events. */
function playerAt(x, y) {
var nodes = document.querySelectorAll('[id^="' + PLAYER_PREFIX + '"]');
for (var i = 0; i < nodes.length; i++) {
var r = nodes[i].getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
if (x >= r.left - 6 && x <= r.right + 6 && y >= r.top - 6 && y <= r.bottom + 6) return uuidFromId(nodes[i].id);
}
return null;
}
function hours(seconds) {
seconds = Number(seconds) || 0;
return seconds >= 3600 ? Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm' : Math.floor(seconds / 60) + 'm';
}
function panel() {
var el = document.getElementById('scopenet-panel');
if (!el) { el = document.createElement('div'); el.id = 'scopenet-panel'; document.body.appendChild(el); }
return el;
}
function render() {
var el = document.getElementById('scopenet-panel');
if (!selected) { if (el) el.remove(); return; }
var p = players[selected] || players[selected.toLowerCase()] || {};
el = panel();
var name = p.name || 'Player';
el.innerHTML =
'<button class="close" aria-label="Close">×</button>' +
'<div class="head"><img alt="" src="assets/playerheads/' + esc(selected) + '.png" onerror="this.src=\'assets/steve.png\'">' +
'<div><h3>' + esc(name) + '</h3>' + (p.title ? '<div class="title">' + esc(p.title) + '</div>' : '') + '</div></div>' +
'<div class="chips">' +
'<span class="chip lv">Level ' + esc(p.level || 1) + '</span>' +
(p.guild ? '<span class="chip guild">[' + esc(p.guildTag) + '] ' + esc(p.guild) + '</span>' : '') +
(p.rank ? '<span class="chip rank">' + esc(p.rank) + '</span>' : '') + '</div>' +
'<div class="facts">' +
'<div>Playtime<strong>' + esc(hours(p.playtime)) + '</strong></div>' +
'<div>Server level<strong>' + esc(p.serverLevel || 1) + '</strong></div>' +
(p.guildRole ? '<div>Guild role<strong>' + esc(p.guildRole) + '</strong></div>' : '') + '</div>' +
'<p class="note">Open this map in the SCOPENET launcher to send a teleport request or message, or to invite ' + esc(name) + ' to your guild.</p>';
el.querySelector('.close').addEventListener('click', function () { select(null); });
}
function select(uuid) {
selected = uuid;
if (embedded) {
window.parent.postMessage(uuid
? { source: 'scopenet-map', type: 'player', uuid: uuid, name: (players[uuid] || {}).name || '' }
: { source: 'scopenet-map', type: 'deselect' }, '*');
} else {
render();
}
}
function refresh() {
fetch(BASE + 'players.json?t=' + Date.now(), { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { if (j && j.players) { players = j.players; if (!embedded && selected) render(); } })
.catch(function () { /* the file appears once the server has written it */ });
}
// A click is a press and release in nearly the same place; a drag moves the map.
var down = null;
document.addEventListener('pointerdown', function (e) { down = { x: e.clientX, y: e.clientY, t: Date.now() }; }, true);
document.addEventListener('pointerup', function (e) {
var start = down;
down = null;
if (!start || e.button > 0) return;
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 6 || Date.now() - start.t > 700) return;
if (e.target && e.target.closest && e.target.closest('#scopenet-panel')) return;
var uuid = playerAt(e.clientX, e.clientY);
if (uuid) select(uuid);
else if (selected) select(null);
}, true);
window.addEventListener('message', function (e) {
if (e.source === window.parent && e.data && e.data.source === 'scopenet-launcher' && e.data.type === 'hello') { /* acknowledged */ }
});
refresh();
setInterval(refresh, 10000);
if (embedded) window.parent.postMessage({ source: 'scopenet-map', type: 'ready' }, '*');
// Exposed for tests.
window.__scopenet = { uuidFromId: uuidFromId, esc: esc, hours: hours };
})();
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#f97316" stroke="#ffffff" stroke-width="2"/><path d="M8 10h3l2 10h10l2-7H12" fill="none" stroke="#fff" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/><circle cx="14" cy="24" r="1.8" fill="#fff"/><circle cx="22" cy="24" r="1.8" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 387 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#34d399" stroke="#ffffff" stroke-width="2"/><path d="M16 7l2.6 6.4 6.9.5-5.3 4.5 1.7 6.7L16 21.3 10.1 25l1.7-6.7-5.3-4.5 6.9-.5z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 270 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="#8b6cff" stroke="#ffffff" stroke-width="2"/><circle cx="16" cy="16" r="6" fill="none" stroke="#fff" stroke-width="2.5"/><circle cx="16" cy="16" r="2" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 290 B

@@ -39,6 +39,16 @@ public final class CommandSet {
throw new IllegalArgumentException("no command " + name);
}
/** What this command set knows that the map can show. */
public net.scopenet.core.map.MapSource mapSource() {
return new net.scopenet.core.map.MapSource() {
@Override public java.util.Map<String, Pos> warps() { return essentials.allWarps(); }
@Override public java.util.Map<java.util.UUID, java.util.Map<String, Pos>> homes() { return essentials.allHomes(); }
@Override public java.util.Map<String, Pos> guildHomes() { return guildCommands.guildHomes(); }
@Override public java.util.Collection<ShopPoints.Shop> shops() { return shops.all(); }
};
}
/** Guild homes by guild id (for the map). */
public java.util.Map<String, Pos> guildHomes() { return guildCommands.guildHomes(); }
@@ -52,6 +52,14 @@ public final class MapModel {
return ((int) Math.round((r + m) * 255) << 16) | ((int) Math.round((g + m) * 255) << 8) | (int) Math.round((b + m) * 255);
}
/** Which Minecraft dimension a BlueMap map shows, judged from its id and name ("world_nether" -> the nether). */
public static String dimensionOf(String id, String name) {
List<String> words = Arrays.asList(((id == null ? "" : id) + " " + (name == null ? "" : name)).toLowerCase(Locale.ROOT).split("[^a-z]+"));
if (words.contains("nether")) return "minecraft:the_nether";
if (words.contains("end")) return "minecraft:the_end";
return "minecraft:overworld";
}
/** The icon address: a full web address as is, a path on the panel made absolute, empty if none. */
public static String iconAddress(String icon, String panelBase) {
if (icon == null || icon.isBlank()) return "";
@@ -17,7 +17,7 @@ public final class MapService {
private final Env env;
private final ClaimIndex claims;
private final CommandSet commands;
private final MapSource places;
private final PlayerCache cache;
private final String panelBase;
private final boolean showHomes;
@@ -26,8 +26,8 @@ public final class MapService {
private volatile long marketAt = Long.MIN_VALUE / 2;
private volatile boolean fetching;
public MapService(Env env, ClaimIndex claims, CommandSet commands, PlayerCache cache, String panelBase, boolean showHomes, String serverName) {
this.env = env; this.claims = claims; this.commands = commands; this.cache = cache; this.panelBase = panelBase; this.showHomes = showHomes; this.serverName = serverName;
public MapService(Env env, ClaimIndex claims, MapSource places, PlayerCache cache, String panelBase, boolean showHomes, String serverName) {
this.env = env; this.claims = claims; this.places = places; this.cache = cache; this.panelBase = panelBase; this.showHomes = showHomes; this.serverName = serverName;
}
public String serverName() { return serverName; }
@@ -52,12 +52,12 @@ public final class MapService {
Map<String, MapModel.NamedPos> guildHomes = new TreeMap<>();
Map<String, String> labels = new HashMap<>();
claims.byDimension().values().forEach(list -> list.forEach(g -> labels.put(g.id(), "[" + g.tag() + "] " + g.name())));
commands.guildHomes().forEach((id, pos) -> guildHomes.put(id, new MapModel.NamedPos(labels.getOrDefault(id, "Guild home"), pos)));
places.guildHomes().forEach((id, pos) -> guildHomes.put(id, new MapModel.NamedPos(labels.getOrDefault(id, "Guild home"), pos)));
Pos spawn = env.platform.worldSpawn("minecraft:overworld");
MapModel.Sources sources = new MapModel.Sources(
env.features.guilds() && env.features.landClaiming() ? claims.byDimension() : Map.of(),
env.features.essentials() ? commands.essentials.allWarps() : Map.of(),
List.of(spawn), showHomes ? commands.essentials.allHomes() : Map.of(), guildHomes, commands.shops.all(),
env.features.essentials() ? places.warps() : Map.of(),
List.of(spawn), showHomes ? places.homes() : Map.of(), guildHomes, places.shops(),
market, players, panelBase, showHomes, names);
return MapModel.build(sources);
}
@@ -0,0 +1,17 @@
package net.scopenet.core.map;
import net.scopenet.core.Pos;
import net.scopenet.core.ShopPoints;
import java.util.Collection;
import java.util.Map;
import java.util.UUID;
/** Where a platform keeps the places the map shows. Each method returns a copy that is safe to read from any thread. */
public interface MapSource {
Map<String, Pos> warps();
Map<UUID, Map<String, Pos>> homes();
/** Guild homes by guild id. */
Map<String, Pos> guildHomes();
Collection<ShopPoints.Shop> shops();
}
@@ -151,6 +151,13 @@ class MapTest {
assertTrue(MapModel.build(sources(Map.of(), List.of(shop), new JsonArray(), false)).pins().stream().anyMatch(p -> p.detail().contains("No listings")));
}
@Test void mapsAreMatchedToDimensions() {
assertEquals("minecraft:overworld", MapModel.dimensionOf("world", "World"));
assertEquals("minecraft:the_nether", MapModel.dimensionOf("world_nether", "Nether"));
assertEquals("minecraft:the_end", MapModel.dimensionOf("world_the_end", "The End"));
assertEquals("minecraft:overworld", MapModel.dimensionOf("legends", "Friends & Legends"), "'end' inside another word isn't the End");
}
@Test void playersJsonHasOnlyPublicFacts() {
JsonObject j = MapModel.playersJson("SMP", List.of(new MapData.OnlinePlayer("u-1", "Alex", 12, "Miner", 4, "Iron", "IRON", "leader", "VIP", 7300)), 123L);
JsonObject a = j.getAsJsonObject("players").getAsJsonObject("u-1");
@@ -300,7 +307,7 @@ class MapTest {
ClaimIndex claims = new ClaimIndex();
claims.apply(JsonParser.parseString("{\"revision\":\"1\",\"guilds\":[{\"id\":\"g1\",\"name\":\"Iron\",\"tag\":\"IRON\",\"icon_url\":\"/textures/x\"}],"
+ "\"claims\":[[\"minecraft:overworld\",0,0,0],[\"minecraft:overworld\",1,0,0]],\"members\":{\"g1\":[]}}").getAsJsonObject());
MapService service = new MapService(kit.env, claims, set, cache, "https://panel.example.com", false, "SMP");
MapService service = new MapService(kit.env, claims, set.mapSource(), cache, "https://panel.example.com", false, "SMP");
service.tick();
MapData data = service.snapshot();
assertEquals(1, data.claims().size());
@@ -40,6 +40,8 @@ public final class ClientState {
public volatile List<ShopItem> shop = List.of();
public volatile Held held;
public volatile long marketAt, shopAt;
/** A window the server asked us to open ("market" or "shop"), taken once by the screen code. */
public volatile String pendingOpen;
/** Called on the client thread with a server message; returns a toast (kind, title, text) or null. */
public String[] handle(String json) {
@@ -94,6 +96,7 @@ public final class ClientState {
shopAt = System.currentTimeMillis();
}
case "notify" -> { return new String[]{str(m, "kind"), str(m, "title"), str(m, "text")}; }
case "open" -> pendingOpen = str(m, "screen");
default -> { }
}
return null;
@@ -23,7 +23,16 @@ public final class Link {
void register() {
ClientPlayNetworking.registerGlobalReceiver(TO_CLIENT, (client, handler, buf, sender) -> {
String json = buf.readUtf(32767 * 3);
client.execute(() -> toast(state.handle(json)));
client.execute(() -> {
toast(state.handle(json));
String open = state.pendingOpen;
state.pendingOpen = null;
// A right-clicked shop point opens its window, unless another screen is already up.
if (open != null && config.enabled && client.screen == null) {
if (open.equals("market")) client.setScreen(new net.scopenet.client.screen.MarketScreen());
else if (open.equals("shop")) client.setScreen(new net.scopenet.client.screen.ShopScreen());
}
});
});
ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> send("hello"));
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> state.reset());
+2
View File
@@ -16,6 +16,7 @@ dependencies {
implementation project(':common')
if (features) {
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
implementation project(':bluemap')
// Optional at runtime: present only when the LuckPerms mod is installed.
compileOnly 'net.luckperms:api:5.4'
}
@@ -31,5 +32,6 @@ processResources {
jar {
archiveBaseName = "scopenet-fabric-${mc}"
from project(':common').sourceSets.main.output
if (features) from project(':bluemap').sourceSets.main.output
}
base.archivesName = "scopenet-fabric-${mc}"
@@ -7,5 +7,5 @@
"entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] },
"mixins": ["scopenet.mixins.json"],
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" },
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*" }
"suggests": { "fabric-api": "*", "luckperms": "*", "placeholder-api": "*", "bluemap": "*" }
}
@@ -0,0 +1,21 @@
package net.scopenet.fabric.features;
import net.scopenet.bluemap.MapAddon;
import net.scopenet.core.map.MapData;
import net.scopenet.core.map.MapService;
import java.util.logging.Logger;
/** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */
final class BlueMapHook {
private BlueMapHook() {}
static Object start(MapService service, Logger log) {
return MapAddon.start(new MapAddon.Feed() {
@Override public MapData snapshot() { return service.snapshot(); }
@Override public com.google.gson.JsonObject playersJson() { return service.playersJson(); }
}, log);
}
static void stop(Object handle) { ((MapAddon) handle).stop(); }
}
@@ -15,12 +15,17 @@ import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.fabricmc.fabric.api.event.player.UseBlockCallback;
import net.minecraft.core.BlockPos;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.scopenet.core.*;
import net.scopenet.core.map.MapService;
import net.scopenet.fabric.api.ScopenetEconomy;
import net.scopenet.integration.Integration;
import net.scopenet.integration.Settings;
@@ -49,6 +54,9 @@ public final class FabricFeatures implements Runnable {
private ClientLink link;
private Perms perms;
private FabricConfig config;
private ActionPoller poller;
private MapService map;
private Object blueMap;
private int ticks;
private String placeholderStatus = "off";
@@ -64,6 +72,17 @@ public final class FabricFeatures implements Runnable {
if (entity instanceof ServerPlayer p && commands != null) commands.essentials.remember(p.getUUID(), platform.wrap(p).pos());
return true;
});
// Right-clicking a shop point opens the market or the shop.
UseBlockCallback.EVENT.register((player, level, hand, hit) -> {
if (level.isClientSide || hand != InteractionHand.MAIN_HAND || !(player instanceof ServerPlayer sp) || commands == null) return InteractionResult.PASS;
BlockPos at = hit.getBlockPos();
Optional<ShopPoints.Shop> shop = commands.shops.at(level.dimension().location().toString(), at.getX(), at.getY(), at.getZ());
if (shop.isEmpty()) return InteractionResult.PASS;
CorePlayer p = platform.wrap(sp);
String kind = shop.get().kind().equals("market") ? "market" : "shop";
if (!link.open(p, kind)) commands.run(kind, p);
return InteractionResult.SUCCESS;
});
ServerPlayNetworking.registerGlobalReceiver(FROM_CLIENT, (s, player, handler, buf, sender) -> {
byte[] data = new byte[buf.readableBytes()];
buf.readBytes(data);
@@ -93,6 +112,14 @@ public final class FabricFeatures implements Runnable {
integration.onNotifications(events -> link.onPanelEvents(events));
Bridge.claimBypass = p -> perms.has(p, "scopenet.claims.bypass");
// Actions from the map, and the BlueMap addon (claims, pins, player panel) when BlueMap is installed.
poller = new ActionPoller(env, (from, to) -> commands.essentials.tpa(from, new String[]{to.name()}));
map = new MapService(env, integration.client().claims(), commands.mapSource(), cache, integration.settings().panel().toString(),
config.bool("bluemap.show_homes", false), s.getMotd());
if (config.bool("bluemap.enabled", true) && net.fabricmc.loader.api.FabricLoader.getInstance().isModLoaded("bluemap")) {
try { blueMap = BlueMapHook.start(map, LOG); } catch (Throwable t) { LOG.warning("BlueMap addon could not start: " + t); }
}
registerCommands(s.getCommands().getDispatcher());
registerPlaceholders();
ScopenetEconomy.install(new ScopenetEconomy(env.panel, r -> platform.runAsync(r)));
@@ -103,6 +130,7 @@ public final class FabricFeatures implements Runnable {
private void stopping() {
ScopenetEconomy.uninstall();
Bridge.claimBypass = p -> false;
if (blueMap != null) { try { BlueMapHook.stop(blueMap); } catch (Throwable ignored) { } blueMap = null; }
if (platform != null) platform.shutdown();
commands = null;
}
@@ -125,6 +153,8 @@ public final class FabricFeatures implements Runnable {
if (ticks % 20 == 0) {
cache.tick(p -> link.pushState(p));
link.tick();
poller.tick();
map.tick();
}
if (ticks % 40 == 0) commands.tick();
}
+2
View File
@@ -6,6 +6,7 @@ repositories {
dependencies {
implementation project(':common')
implementation project(':api')
implementation project(':bluemap')
implementation 'com.google.code.gson:gson:2.10.1'
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
// Optional integrations: the plugin detects these at runtime and works without them.
@@ -22,6 +23,7 @@ jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
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) } }
exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA'
}
@@ -1,6 +1,11 @@
package net.scopenet.paper;
import net.scopenet.core.*;
import net.scopenet.core.map.MapService;
import net.scopenet.core.map.MapSource;
import net.scopenet.paper.commands.EconomyHandler;
import net.scopenet.paper.commands.EssentialsHandler;
import net.scopenet.paper.commands.GuildHandler;
import net.scopenet.integration.Integration;
import org.bukkit.Bukkit;
import org.bukkit.Material;
@@ -22,9 +27,16 @@ final class ClientLinkBridge implements PluginMessageListener {
private final PlayerCache cache;
private final ClientLink link;
private final Placeholders placeholders;
private final Integration integration;
private final ShopPoints shops;
private ActionPoller poller;
private MapService mapService;
private Object blueMap;
private EconomyHandler economy;
ClientLinkBridge(ScopenetPlugin plugin, Integration integration) {
this.plugin = plugin;
this.integration = integration;
PaperPlatform platform = new PaperPlatform(plugin);
Features features = new Features(true, plugin.getConfig().getBoolean("economy.enabled", true), integration.settings().guildsEnabled(),
integration.settings().socialEnabled(), integration.settings().landClaimingEnabled(),
@@ -33,6 +45,7 @@ final class ClientLinkBridge implements PluginMessageListener {
env = new Env(platform, new PanelAdapter(integration), features, Path.of(plugin.getDataFolder().getPath()), System::currentTimeMillis, new Random(), log);
cache = new PlayerCache(env);
placeholders = new Placeholders(env, cache);
shops = new ShopPoints(Path.of(plugin.getDataFolder().getPath()).resolve("shops.json"));
link = new ClientLink(env, cache, Bukkit.getServer().getName(), (p, bytes) -> {
Player target = Bukkit.getPlayer(p.uuid());
if (target != null) target.sendPluginMessage(plugin, Wire.S2C, bytes);
@@ -50,7 +63,70 @@ final class ClientLinkBridge implements PluginMessageListener {
}, 40, 20);
}
/**
* Shop points, actions from the map, and the BlueMap addon. Called once the command handlers exist; any of them may be
* null when that system is switched off.
*/
void startMap(EssentialsHandler essentials, GuildHandler guilds, EconomyHandler economy) {
this.economy = economy;
Bukkit.getPluginManager().registerEvents(new ShopListener(), plugin);
poller = new ActionPoller(env, (from, to) -> {
Player a = Bukkit.getPlayer(from.uuid()), b = Bukkit.getPlayer(to.uuid());
if (a != null && b != null && essentials != null) essentials.requestTpa(a, b);
});
MapSource source = new MapSource() {
@Override public Map<String, Pos> warps() { return essentials == null ? Map.of() : essentials.warpsForMap(); }
@Override public Map<UUID, Map<String, Pos>> homes() { return essentials == null ? Map.of() : essentials.homesForMap(); }
@Override public Map<String, Pos> guildHomes() { return guilds == null ? Map.of() : guilds.guildHomesForMap(); }
@Override public Collection<ShopPoints.Shop> shops() { return shops.all(); }
};
mapService = new MapService(env, integration.client().claims(), source, cache, integration.settings().panel().toString(),
plugin.getConfig().getBoolean("bluemap.show-homes", false), Bukkit.getServer().getName());
Bukkit.getScheduler().runTaskTimer(plugin, () -> { poller.tick(); mapService.tick(); }, 60, 20);
if (plugin.getConfig().getBoolean("bluemap.enabled", true) && Bukkit.getPluginManager().getPlugin("BlueMap") != null) {
blueMap = BlueMapHook.start(mapService, plugin.getLogger());
}
}
/** /market point add|remove|list, on the block the player is looking at (or standing on). */
void shopCommand(Player player, String[] args) {
org.bukkit.block.Block looked = player.getTargetBlockExact(6);
org.bukkit.Location l = looked != null ? looked.getLocation() : player.getLocation().subtract(0, 1, 0);
shops.command(new PaperPlatform.PaperPlayer(player), args, new Pos(ScopenetPlugin.dimension(l.getWorld()), l.getBlockX(), l.getBlockY(), l.getBlockZ(), 0, 0));
}
/** Right-clicking a shop point opens the market or shop: the client mod's window if they have it, otherwise the chest GUI. */
private final class ShopListener implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(ignoreCancelled = true)
public void onInteract(org.bukkit.event.player.PlayerInteractEvent event) {
if (event.getAction() != org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK || event.getHand() != org.bukkit.inventory.EquipmentSlot.HAND) return;
org.bukkit.block.Block block = event.getClickedBlock();
if (block == null) return;
Optional<ShopPoints.Shop> shop = shops.at(ScopenetPlugin.dimension(block.getWorld()), block.getX(), block.getY(), block.getZ());
if (shop.isEmpty() || economy == null) return;
event.setCancelled(true);
Player player = event.getPlayer();
String kind = shop.get().kind().equals("market") ? "market" : "shop";
if (!link.open(new PaperPlatform.PaperPlayer(player), kind)) {
if (kind.equals("market")) economy.openMarket(player); else economy.openShop(player);
}
}
}
/** Kept apart so BlueMap's classes are only loaded when BlueMap is installed. */
private static final class BlueMapHook {
static Object start(MapService service, Logger log) {
return net.scopenet.bluemap.MapAddon.start(new net.scopenet.bluemap.MapAddon.Feed() {
@Override public net.scopenet.core.map.MapData snapshot() { return service.snapshot(); }
@Override public com.google.gson.JsonObject playersJson() { return service.playersJson(); }
}, log);
}
static void stop(Object handle) { ((net.scopenet.bluemap.MapAddon) handle).stop(); }
}
void stop() {
if (blueMap != null) { BlueMapHook.stop(blueMap); blueMap = null; }
Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C);
Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this);
}
@@ -78,7 +154,13 @@ final class ClientLinkBridge implements PluginMessageListener {
return out;
}
@Override public Optional<Pos> safeSurface(String world, int x, int z) { return Optional.empty(); }
@Override public Pos worldSpawn(String world) { return new Pos(world, 0, 64, 0, 0, 0); }
@Override public Pos worldSpawn(String world) {
for (org.bukkit.World w : Bukkit.getWorlds()) {
if (ScopenetPlugin.dimension(w).equals(world)) { org.bukkit.Location l = w.getSpawnLocation(); return new Pos(world, l.getX(), l.getY(), l.getZ(), 0, 0); }
}
org.bukkit.Location l = Bukkit.getWorlds().get(0).getSpawnLocation();
return new Pos(ScopenetPlugin.dimension(Bukkit.getWorlds().get(0)), l.getX(), l.getY(), l.getZ(), 0, 0);
}
@Override public void runMain(Runnable task) {
if (Bukkit.isPrimaryThread()) task.run(); else Bukkit.getScheduler().runTask(plugin, task);
}
@@ -25,6 +25,8 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
private ScopenetApiImpl api;
private CompatManager compat;
private ClientLinkBridge clientLink;
private EssentialsHandler essentialsHandler;
private GuildHandler guildHandler;
@Override public void onEnable() {
// Install the deny-by-default listener even if configuration is invalid.
@@ -78,6 +80,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
// Register Essentials commands & GUI
{
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"};
for (String c : essCmds) bindCommand(c, essentials);
@@ -94,12 +97,16 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
// Register Guilds & Land Claims commands & GUI
{
GuildHandler guilds = new GuildHandler(this, integration, economy);
guildHandler = guilds;
getServer().getPluginManager().registerEvents(guilds, this);
bindCommand("guild", guilds);
bindCommand("claim", guilds);
bindCommand("unclaim", guilds);
}
// Physical shops, actions from the map, and (when BlueMap is installed) claims, pins and the player panel on it.
clientLink.startMap(essentialsHandler, guildHandler, economy);
// Reloading an auth plugin cannot silently admit existing sessions.
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer("SCOPENET restarted. Please reconnect.");
getLogger().info("SCOPENET integration initialized. Configured modules:");
@@ -168,6 +175,11 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
return world.getKey().toString();
}
/** {@code /market point ...}: manage shop points (blocks that open the market or shop when right-clicked). */
public void shopCommand(Player player, String[] args) {
if (clientLink != null) clientLink.shopCommand(player, args);
}
private void bindCommand(String name, org.bukkit.command.CommandExecutor executor) {
org.bukkit.command.PluginCommand cmd = getCommand(name);
if (cmd != null) {
@@ -109,6 +109,12 @@ public final class EconomyHandler implements CommandExecutor, Listener {
return true;
}
/** Open the player market window (used by physical shop points). */
public void openMarket(Player player) { handleMarket(player, new String[0], false); }
/** Open the server shop window (used by physical shop points). */
public void openShop(Player player) { handleShop(player); }
private void handleBalance(Player player) {
player.sendMessage(ChatColor.GRAY + "Fetching your balance...");
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
@@ -269,6 +275,10 @@ public final class EconomyHandler implements CommandExecutor, Listener {
/** {@code asGuild}: list on behalf of the player's guild, or buy with its bank (see /guild market). */
void handleMarket(Player player, String[] args, boolean asGuild) {
if (!asGuild && args.length >= 1 && args[0].equalsIgnoreCase("point")) {
plugin.shopCommand(player, java.util.Arrays.copyOfRange(args, 1, args.length));
return;
}
if (args.length >= 2 && args[0].equalsIgnoreCase("buy")) {
if (!asGuild && !player.hasPermission("scopenet.command.market.buy")) {
player.sendMessage(ChatColor.RED + "You do not have permission to buy from the market.");
@@ -172,6 +172,28 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
player.sendMessage(ChatColor.GREEN + "Teleported to spawn.");
}
private static net.scopenet.core.Pos toPos(Location l) {
return new net.scopenet.core.Pos(l.getWorld() == null ? "minecraft:overworld" : ScopenetPlugin.dimension(l.getWorld()), l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch());
}
/** Warps as shown on the map. A copy. */
public Map<String, net.scopenet.core.Pos> warpsForMap() {
Map<String, net.scopenet.core.Pos> out = new TreeMap<>();
warps.forEach((name, loc) -> { if (loc.getWorld() != null) out.put(name, toPos(loc)); });
return out;
}
/** Every player's homes, for the map (only shown if the admin allows it). A copy. */
public Map<UUID, Map<String, net.scopenet.core.Pos>> homesForMap() {
Map<UUID, Map<String, net.scopenet.core.Pos>> out = new HashMap<>();
homes.forEach((id, byName) -> {
Map<String, net.scopenet.core.Pos> mine = new TreeMap<>();
byName.forEach((name, loc) -> { if (loc.getWorld() != null) mine.put(name, toPos(loc)); });
out.put(id, mine);
});
return out;
}
private void handleHome(Player player, String[] args) {
Map<String, Location> playerHomes = homes.computeIfAbsent(player.getUniqueId(), k -> new HashMap<>());
if (args.length == 0) {
@@ -277,6 +299,11 @@ public final class EssentialsHandler implements CommandExecutor, Listener {
return;
}
requestTpa(player, target);
}
/** Ask {@code target} to accept a teleport of {@code player} to them. Also used for requests made from the map. */
public void requestTpa(Player player, Player target) {
pendingTpa.put(target.getUniqueId(), player.getUniqueId());
tpaTimestamp.put(target.getUniqueId(), System.currentTimeMillis());
@@ -99,7 +99,7 @@ public final class GuildHandler implements CommandExecutor, Listener {
String sub = args[0].toLowerCase();
String permission = sub.equals("c") ? "chat" : sub;
if (java.util.Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay").contains(permission)
if (java.util.Set.of("create", "leave", "claim", "unclaim", "map", "chat", "sethome", "home", "members", "bank", "sell", "market", "pay", "invite", "accept", "decline").contains(permission)
&& !player.hasPermission("scopenet.command.guild." + permission)) {
player.sendMessage(ChatColor.RED + "You do not have permission to use /guild " + permission + ".");
return true;
@@ -124,6 +124,9 @@ public final class GuildHandler implements CommandExecutor, Listener {
case "members" -> handleMembers(player);
case "bank" -> bank.bank(player, Arrays.copyOfRange(args, 1, args.length));
case "pay" -> bank.pay(player, Arrays.copyOfRange(args, 1, args.length));
case "invite" -> handleInvite(player, args);
case "accept" -> handleInviteAnswer(player, args, true);
case "decline", "deny" -> handleInviteAnswer(player, args, false);
case "sell" -> economy.handleSell(player, Arrays.copyOfRange(args, 1, args.length), true);
case "market" -> economy.handleMarket(player, Arrays.copyOfRange(args, 1, args.length), true);
default -> sendGuildHelp(player);
@@ -134,6 +137,46 @@ public final class GuildHandler implements CommandExecutor, Listener {
return false;
}
private void handleInvite(Player player, String[] args) {
if (args.length < 2) { player.sendMessage(ChatColor.RED + "Usage: /guild invite <player>"); return; }
com.google.gson.JsonObject body = new com.google.gson.JsonObject();
body.addProperty("uuid", player.getUniqueId().toString());
body.addProperty("target", args[1]);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
com.google.gson.JsonObject r = integration.client().post("guilds/invite/send", body);
player.sendMessage(ChatColor.GREEN + "Invitation sent to " + ChatColor.YELLOW + (r.has("player") ? r.get("player").getAsString() : args[1]) + ChatColor.GREEN + ".");
} catch (Exception e) {
player.sendMessage(ChatColor.RED + "Could not invite: " + e.getMessage());
}
});
}
private void handleInviteAnswer(Player player, String[] args, boolean accept) {
com.google.gson.JsonObject body = new com.google.gson.JsonObject();
body.addProperty("uuid", player.getUniqueId().toString());
body.addProperty("tag", args.length > 1 ? args[1] : "");
body.addProperty("accept", accept);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
com.google.gson.JsonObject r = integration.client().post("guilds/invite/respond", body);
if (accept) integration.client().requestClaimRefresh();
player.sendMessage(accept
? ChatColor.GREEN + "Welcome to " + ChatColor.YELLOW + "[" + r.get("tag").getAsString() + "] " + r.get("guild").getAsString() + ChatColor.GREEN + "!"
: ChatColor.YELLOW + "Invitation declined.");
} catch (Exception e) {
player.sendMessage(ChatColor.RED + e.getMessage());
}
});
}
/** Guild homes by guild id, for the map. */
public Map<String, net.scopenet.core.Pos> guildHomesForMap() {
Map<String, net.scopenet.core.Pos> out = new java.util.TreeMap<>();
guildHomes.forEach((id, l) -> { if (l.getWorld() != null) out.put(id, new net.scopenet.core.Pos(ScopenetPlugin.dimension(l.getWorld()), l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch())); });
return out;
}
private void sendGuildHelp(Player player) {
player.sendMessage(ChatColor.GOLD + "=== SCOPENET Guild Commands ===");
player.sendMessage(ChatColor.YELLOW + "/guild" + ChatColor.GRAY + " - Open interactive Guild Dashboard GUI");
@@ -148,6 +191,8 @@ public final class GuildHandler implements CommandExecutor, Listener {
player.sendMessage(ChatColor.YELLOW + "/guild sell [hand]" + ChatColor.GRAY + " - Sell items to the shop for the guild bank");
player.sendMessage(ChatColor.YELLOW + "/guild market sell <price> | buy <#id>" + ChatColor.GRAY + " - Trade on the market as a guild");
player.sendMessage(ChatColor.YELLOW + "/guild pay <tag> <amount>" + ChatColor.GRAY + " - Pay another guild");
player.sendMessage(ChatColor.YELLOW + "/guild invite <player>" + ChatColor.GRAY + " - Invite a player (leaders and officers)");
player.sendMessage(ChatColor.YELLOW + "/guild accept [tag] | decline [tag]" + ChatColor.GRAY + " - Answer a guild invitation");
}
private void handleClaim(Player player) {
@@ -94,6 +94,17 @@ integrations:
enabled: true
report-interval-seconds: 30
# ==============================================================================
# BlueMap
# With BlueMap installed, SCOPENET adds guild claims (coloured, with the guild's icon and name), spawn, warps, guild
# homes, markets and shops to the 3D map, and a player panel when you click someone. Put the map's web address in the
# panel (Servers -> this server -> Map address) and players get a Live Map button in the launcher.
# ==============================================================================
bluemap:
enabled: true
# Show every player's homes as pins. Off by default: homes are private, and the map is public to anyone with the link.
show-homes: false
# ==============================================================================
# Client mod link
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
@@ -4,7 +4,7 @@ main: net.scopenet.paper.ScopenetPlugin
api-version: '1.20'
description: SCOPENET private authentication, access control and activity reporting.
load: STARTUP
softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark]
softdepend: [LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, WorldEdit, spark, BlueMap]
commands:
scopenet:
@@ -242,6 +242,18 @@ permissions:
scopenet.command.guild.pay:
description: Pay another guild (/guild pay)
default: true
scopenet.command.guild.invite:
description: Invite a player to your guild (/guild invite); the panel also requires leader or officer
default: true
scopenet.command.guild.accept:
description: Accept a guild invitation (/guild accept)
default: true
scopenet.command.guild.decline:
description: Decline a guild invitation (/guild decline)
default: true
scopenet.command.market.point:
description: Create and remove shop points, blocks that open the market or shop (/market point)
default: op
scopenet.command.sell.hand:
description: Use /sell hand
default: true
@@ -278,6 +290,9 @@ permissions:
scopenet.command.guild.market.sell: true
scopenet.command.guild.market.buy: true
scopenet.command.guild.pay: true
scopenet.command.guild.invite: true
scopenet.command.guild.accept: true
scopenet.command.guild.decline: true
scopenet.command.*:
description: Every SCOPENET command and subcommand, including admin ones
default: false
@@ -328,8 +343,12 @@ permissions:
scopenet.command.guild.market.sell: true
scopenet.command.guild.market.buy: true
scopenet.command.guild.pay: true
scopenet.command.guild.invite: true
scopenet.command.guild.accept: true
scopenet.command.guild.decline: true
scopenet.command.sell.hand: true
scopenet.command.market.sell: true
scopenet.command.market.point: true
scopenet.command.market.buy: true
scopenet.command.economy.*:
description: All economy commands
+1 -1
View File
@@ -6,7 +6,7 @@ pluginManagement {
}
}
rootProject.name = 'scopenet-integrations'
include 'common', 'api', 'paper'
include 'common', 'api', 'paper', 'bluemap'
// Build loaders separately: Forge and Loom use different Gradle generations.
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'