diff --git a/examples/fabric-server/scopenet.properties b/examples/fabric-server/scopenet.properties index 40e9fc5..5ba77dd 100644 --- a/examples/fabric-server/scopenet.properties +++ b/examples/fabric-server/scopenet.properties @@ -52,3 +52,10 @@ placeholders.enabled=true # --- Client mod link (1.20.1) ------------------------------------------------ # Send balance, level, guild and claim info to players who have the SCOPENET client mod. clientlink.enabled=true + +# --- BlueMap (1.20.1) -------------------------------------------------------- +# With the BlueMap mod installed, adds guild claims, spawn, warps, guild homes, markets and shops to the 3D map, and a +# player panel when you click someone. Set the map's web address in the panel (Servers -> this server -> Map address). +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. +bluemap.show_homes=false diff --git a/examples/paper/config.yml b/examples/paper/config.yml index d22b626..bcf6ab3 100644 --- a/examples/paper/config.yml +++ b/examples/paper/config.yml @@ -114,6 +114,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 diff --git a/integrations/bluemap/build.gradle b/integrations/bluemap/build.gradle new file mode 100644 index 0000000..6f68fc0 --- /dev/null +++ b/integrations/bluemap/build.gradle @@ -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' +} diff --git a/integrations/bluemap/src/main/java/net/scopenet/bluemap/MapAddon.java b/integrations/bluemap/src/main/java/net/scopenet/bluemap/MapAddon.java new file mode 100644 index 0000000..aa8b107 --- /dev/null +++ b/integrations/bluemap/src/main/java/net/scopenet/bluemap/MapAddon.java @@ -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: + * + * 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 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 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 points = new ArrayList<>(); + for (ChunkRegions.Point p : ring.points()) points.add(new Vector2d(p.x(), p.z())); + return new Shape(points); + } + + private static Map claimMarkers(MapData data, String dimension) { + Map 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 guildMarkers(MapData data, String dimension) { + Map 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 pinMarkers(MapData data, String dimension, boolean shops) { + Map 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; + } +} diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/guild-home.svg b/integrations/bluemap/src/main/resources/assets/scopenet/guild-home.svg new file mode 100644 index 0000000..bb2f090 --- /dev/null +++ b/integrations/bluemap/src/main/resources/assets/scopenet/guild-home.svg @@ -0,0 +1 @@ + diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/guild.svg b/integrations/bluemap/src/main/resources/assets/scopenet/guild.svg new file mode 100644 index 0000000..2028d40 --- /dev/null +++ b/integrations/bluemap/src/main/resources/assets/scopenet/guild.svg @@ -0,0 +1 @@ + diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/home.svg b/integrations/bluemap/src/main/resources/assets/scopenet/home.svg new file mode 100644 index 0000000..cdb1214 --- /dev/null +++ b/integrations/bluemap/src/main/resources/assets/scopenet/home.svg @@ -0,0 +1 @@ + diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/market.svg b/integrations/bluemap/src/main/resources/assets/scopenet/market.svg new file mode 100644 index 0000000..5ad9750 --- /dev/null +++ b/integrations/bluemap/src/main/resources/assets/scopenet/market.svg @@ -0,0 +1 @@ + diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.css b/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.css new file mode 100644 index 0000000..ee98a19 --- /dev/null +++ b/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.css @@ -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; } diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.js b/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.js new file mode 100644 index 0000000..3f93b23 --- /dev/null +++ b/integrations/bluemap/src/main/resources/assets/scopenet/scopenet.js @@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + /** The uuid behind a player marker element, or null. BlueMap ids them "bm-marker-bm-player-". */ + 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 = + '' + + '
' + + '

' + esc(name) + '

' + (p.title ? '
' + esc(p.title) + '
' : '') + '
' + + '
' + + 'Level ' + esc(p.level || 1) + '' + + (p.guild ? '[' + esc(p.guildTag) + '] ' + esc(p.guild) + '' : '') + + (p.rank ? '' + esc(p.rank) + '' : '') + '
' + + '
' + + '
Playtime' + esc(hours(p.playtime)) + '
' + + '
Server level' + esc(p.serverLevel || 1) + '
' + + (p.guildRole ? '
Guild role' + esc(p.guildRole) + '
' : '') + '
' + + '

Open this map in the SCOPENET launcher to send a teleport request or message, or to invite ' + esc(name) + ' to your guild.

'; + 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 }; +})(); diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/shop.svg b/integrations/bluemap/src/main/resources/assets/scopenet/shop.svg new file mode 100644 index 0000000..9e1f3f0 --- /dev/null +++ b/integrations/bluemap/src/main/resources/assets/scopenet/shop.svg @@ -0,0 +1 @@ + diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/spawn.svg b/integrations/bluemap/src/main/resources/assets/scopenet/spawn.svg new file mode 100644 index 0000000..63c3e83 --- /dev/null +++ b/integrations/bluemap/src/main/resources/assets/scopenet/spawn.svg @@ -0,0 +1 @@ + diff --git a/integrations/bluemap/src/main/resources/assets/scopenet/warp.svg b/integrations/bluemap/src/main/resources/assets/scopenet/warp.svg new file mode 100644 index 0000000..3feb25d --- /dev/null +++ b/integrations/bluemap/src/main/resources/assets/scopenet/warp.svg @@ -0,0 +1 @@ + diff --git a/integrations/common/src/main/java/net/scopenet/core/CommandSet.java b/integrations/common/src/main/java/net/scopenet/core/CommandSet.java index 078eb1d..da5d758 100644 --- a/integrations/common/src/main/java/net/scopenet/core/CommandSet.java +++ b/integrations/common/src/main/java/net/scopenet/core/CommandSet.java @@ -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 warps() { return essentials.allWarps(); } + @Override public java.util.Map> homes() { return essentials.allHomes(); } + @Override public java.util.Map guildHomes() { return guildCommands.guildHomes(); } + @Override public java.util.Collection shops() { return shops.all(); } + }; + } + /** Guild homes by guild id (for the map). */ public java.util.Map guildHomes() { return guildCommands.guildHomes(); } diff --git a/integrations/common/src/main/java/net/scopenet/core/map/MapModel.java b/integrations/common/src/main/java/net/scopenet/core/map/MapModel.java index 2c72a29..dfdb3fe 100644 --- a/integrations/common/src/main/java/net/scopenet/core/map/MapModel.java +++ b/integrations/common/src/main/java/net/scopenet/core/map/MapModel.java @@ -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 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 ""; diff --git a/integrations/common/src/main/java/net/scopenet/core/map/MapService.java b/integrations/common/src/main/java/net/scopenet/core/map/MapService.java index f7b362c..18fc3b8 100644 --- a/integrations/common/src/main/java/net/scopenet/core/map/MapService.java +++ b/integrations/common/src/main/java/net/scopenet/core/map/MapService.java @@ -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 guildHomes = new TreeMap<>(); Map 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); } diff --git a/integrations/common/src/main/java/net/scopenet/core/map/MapSource.java b/integrations/common/src/main/java/net/scopenet/core/map/MapSource.java new file mode 100644 index 0000000..74a170a --- /dev/null +++ b/integrations/common/src/main/java/net/scopenet/core/map/MapSource.java @@ -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 warps(); + Map> homes(); + /** Guild homes by guild id. */ + Map guildHomes(); + Collection shops(); +} diff --git a/integrations/common/src/test/java/net/scopenet/core/MapTest.java b/integrations/common/src/test/java/net/scopenet/core/MapTest.java index c362786..8e8f38b 100644 --- a/integrations/common/src/test/java/net/scopenet/core/MapTest.java +++ b/integrations/common/src/test/java/net/scopenet/core/MapTest.java @@ -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()); diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/ClientState.java b/integrations/fabric-client/src/main/java/net/scopenet/client/ClientState.java index 6e40f86..1e70056 100644 --- a/integrations/fabric-client/src/main/java/net/scopenet/client/ClientState.java +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/ClientState.java @@ -40,6 +40,8 @@ public final class ClientState { public volatile List 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; diff --git a/integrations/fabric-client/src/main/java/net/scopenet/client/Link.java b/integrations/fabric-client/src/main/java/net/scopenet/client/Link.java index 4de4979..9d06bd9 100644 --- a/integrations/fabric-client/src/main/java/net/scopenet/client/Link.java +++ b/integrations/fabric-client/src/main/java/net/scopenet/client/Link.java @@ -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()); diff --git a/integrations/fabric/build.gradle b/integrations/fabric/build.gradle index 2fe3891..613d817 100644 --- a/integrations/fabric/build.gradle +++ b/integrations/fabric/build.gradle @@ -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}" diff --git a/integrations/fabric/src/main/resources/fabric.mod.json b/integrations/fabric/src/main/resources/fabric.mod.json index 547db89..535e65d 100644 --- a/integrations/fabric/src/main/resources/fabric.mod.json +++ b/integrations/fabric/src/main/resources/fabric.mod.json @@ -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": "*" } } diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/BlueMapHook.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/BlueMapHook.java new file mode 100644 index 0000000..52b473a --- /dev/null +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/BlueMapHook.java @@ -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(); } +} diff --git a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java index ee72d63..505e2ce 100644 --- a/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java +++ b/integrations/fabric/src/mc1.20.1/java/net/scopenet/fabric/features/FabricFeatures.java @@ -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 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(); } diff --git a/integrations/paper/build.gradle b/integrations/paper/build.gradle index b0c19ed..2fb22d7 100644 --- a/integrations/paper/build.gradle +++ b/integrations/paper/build.gradle @@ -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' } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java b/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java index 3f16c51..b42c203 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ClientLinkBridge.java @@ -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 warps() { return essentials == null ? Map.of() : essentials.warpsForMap(); } + @Override public Map> homes() { return essentials == null ? Map.of() : essentials.homesForMap(); } + @Override public Map guildHomes() { return guilds == null ? Map.of() : guilds.guildHomesForMap(); } + @Override public Collection 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 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 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); } diff --git a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java index 6f069ea..137a406 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/ScopenetPlugin.java @@ -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) { diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java index dba9176..ded307a 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/EconomyHandler.java @@ -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."); diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java index cdbe561..b845155 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/EssentialsHandler.java @@ -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 warpsForMap() { + Map 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> homesForMap() { + Map> out = new HashMap<>(); + homes.forEach((id, byName) -> { + Map 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 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()); diff --git a/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java b/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java index 3ef18e0..830a225 100644 --- a/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java +++ b/integrations/paper/src/main/java/net/scopenet/paper/commands/GuildHandler.java @@ -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 "); 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 guildHomesForMap() { + Map 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 | buy <#id>" + ChatColor.GRAY + " - Trade on the market as a guild"); player.sendMessage(ChatColor.YELLOW + "/guild pay " + ChatColor.GRAY + " - Pay another guild"); + player.sendMessage(ChatColor.YELLOW + "/guild invite " + 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) { diff --git a/integrations/paper/src/main/resources/config.yml b/integrations/paper/src/main/resources/config.yml index 5f1614e..44f1f3d 100644 --- a/integrations/paper/src/main/resources/config.yml +++ b/integrations/paper/src/main/resources/config.yml @@ -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 diff --git a/integrations/paper/src/main/resources/plugin.yml b/integrations/paper/src/main/resources/plugin.yml index 059519c..9b62606 100644 --- a/integrations/paper/src/main/resources/plugin.yml +++ b/integrations/paper/src/main/resources/plugin.yml @@ -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 diff --git a/integrations/settings.gradle b/integrations/settings.gradle index 91a2dcc..558b8bb 100644 --- a/integrations/settings.gradle +++ b/integrations/settings.gradle @@ -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'