Fabric 1.20.1: server features (commands, LuckPerms, Placeholder API, economy API, client link) and client mod (HUD, editor, shop and market windows); Paper client link; docs and examples

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 19:38:18 +00:00
1 parent 84c9d46892
commit 2bcdf26351
43 files changed
+2112 -42

No files matched your search

@@ -0,0 +1,108 @@
package net.scopenet.paper;
import net.scopenet.core.*;
import net.scopenet.integration.Integration;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.messaging.PluginMessageListener;
import java.nio.file.Path;
import java.util.*;
import java.util.logging.Logger;
/**
* Talks to the optional SCOPENET client mod (HUD, claim overlay, shop and market windows) through plugin messaging,
* using the same shared logic the Fabric server mod uses. Display only: the client's actions are ordinary commands.
*/
final class ClientLinkBridge implements PluginMessageListener {
private final ScopenetPlugin plugin;
private final Env env;
private final PlayerCache cache;
private final ClientLink link;
private final Placeholders placeholders;
ClientLinkBridge(ScopenetPlugin plugin, Integration integration) {
this.plugin = plugin;
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(),
plugin.getConfig().getBoolean("clientlink.enabled", true), plugin.getConfig().getString("economy.currency-symbol", "$"));
Logger log = plugin.getLogger();
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);
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);
});
}
ClientLink link() { return link; }
void start() {
Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, Wire.S2C);
Bukkit.getMessenger().registerIncomingPluginChannel(plugin, Wire.C2S, this);
Bukkit.getScheduler().runTaskTimer(plugin, () -> {
cache.tick(p -> link.pushState(p));
link.tick();
}, 40, 20);
}
void stop() {
Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, Wire.S2C);
Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, Wire.C2S, this);
}
void quit(Player player) { link.disconnected(player.getUniqueId()); }
@Override public void onPluginMessageReceived(String channel, Player player, byte[] message) {
if (!Wire.C2S.equals(channel)) return;
link.receive(new PaperPlatform.PaperPlayer(player), message);
}
/** Panel events (level-ups, achievements, guild changes) become toasts on clients. Any thread. */
void panelEvents(com.google.gson.JsonArray events) { link.onPanelEvents(events); }
/** Bukkit adapters for the shared logic. Only what the client link needs is real. */
static final class PaperPlatform implements Platform {
private final ScopenetPlugin plugin;
PaperPlatform(ScopenetPlugin plugin) { this.plugin = plugin; }
@Override public Optional<CorePlayer> player(UUID uuid) { return Optional.ofNullable(Bukkit.getPlayer(uuid)).map(PaperPlayer::new); }
@Override public Optional<CorePlayer> playerByName(String name) { return Optional.ofNullable(Bukkit.getPlayerExact(name)).map(PaperPlayer::new); }
@Override public Collection<? extends CorePlayer> online() {
List<CorePlayer> out = new ArrayList<>();
for (Player p : Bukkit.getOnlinePlayers()) out.add(new PaperPlayer(p));
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 void runMain(Runnable task) {
if (Bukkit.isPrimaryThread()) task.run(); else Bukkit.getScheduler().runTask(plugin, task);
}
@Override public void runAsync(Runnable task) { Bukkit.getScheduler().runTaskAsynchronously(plugin, task); }
record PaperPlayer(Player p) implements CorePlayer {
@Override public UUID uuid() { return p.getUniqueId(); }
@Override public String name() { return p.getName(); }
@Override public Pos pos() {
org.bukkit.Location l = p.getLocation();
return new Pos(ScopenetPlugin.dimension(l.getWorld()), l.getX(), l.getY(), l.getZ(), l.getYaw(), l.getPitch());
}
@Override public void send(String message) { p.sendMessage(message); }
@Override public void teleport(Pos destination) { }
@Override public boolean hasPermission(String node) { return p.hasPermission(node); }
@Override public long playtimeSeconds() { return p.getStatistic(org.bukkit.Statistic.PLAY_ONE_MINUTE) / 20L; }
@Override public Optional<Item> heldItem() {
ItemStack hand = p.getInventory().getItemInMainHand();
if (hand.getType() == Material.AIR) return Optional.empty();
String name = hand.hasItemMeta() && hand.getItemMeta().hasDisplayName() ? hand.getItemMeta().getDisplayName() : hand.getType().name().replace('_', ' ');
return Optional.of(new Item(hand.getType().name(), name, hand.getAmount(), ""));
}
@Override public void clearHeld() { }
@Override public void give(Item item) { }
}
}
}
@@ -24,6 +24,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
private EconomyHandler economy;
private ScopenetApiImpl api;
private CompatManager compat;
private ClientLinkBridge clientLink;
@Override public void onEnable() {
// Install the deny-by-default listener even if configuration is invalid.
@@ -58,7 +59,11 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
api = new ScopenetApiImpl(integration.client());
ScopenetApiProvider.register(api);
getServer().getServicesManager().register(ScopenetApi.class, api, this, org.bukkit.plugin.ServicePriority.Normal);
integration.onNotifications(new EventBridge(this, api, getLogger())::accept);
// Optional client mod (HUD, claim overlay, shop and market windows).
clientLink = new ClientLinkBridge(this, integration);
clientLink.start();
EventBridge events = new EventBridge(this, api, getLogger());
integration.onNotifications(list -> { events.accept(list); clientLink.panelEvents(list); });
Bukkit.getScheduler().runTaskTimer(this, this::warmProfiles, 100, 20 * 20);
// Optional integrations (LuckPerms, PlaceholderAPI, Vault, CoreProtect, WorldGuard, Spark) are found and
@@ -180,6 +185,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
@Override public void onDisable() {
if (compat != null) compat.stop();
if (clientLink != null) clientLink.stop();
if (api != null) {
getServer().getServicesManager().unregister(ScopenetApi.class, api);
ScopenetApiProvider.unregister(api);
@@ -213,6 +219,7 @@ public final class ScopenetPlugin extends JavaPlugin implements Listener {
public void leave(PlayerQuitEvent event) {
deniedAt.remove(event.getPlayer().getUniqueId());
if (api != null) api.forget(event.getPlayer().getUniqueId());
if (clientLink != null) clientLink.quit(event.getPlayer());
if (integration != null) integration.activity.leave(event.getPlayer().getUniqueId(), event.getPlayer().getName());
}
@@ -269,17 +269,21 @@ 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 >= 2 && args[0].equalsIgnoreCase("buy")) {
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.");
return;
}
long listing;
try { listing = Long.parseLong(args[1].replace("#", "")); } catch (NumberFormatException e) {
player.sendMessage(ChatColor.RED + "Usage: /guild market buy <listing number> (shown as Listing #n in /market)");
player.sendMessage(ChatColor.RED + "Usage: /" + (asGuild ? "guild market" : "market") + " buy <listing number> (shown as Listing #n in /market)");
return;
}
JsonObject payload = new JsonObject();
payload.addProperty("listing_id", listing);
payload.addProperty("buyer_uuid", player.getUniqueId().toString());
payload.addProperty("buyer_name", player.getName());
payload.addProperty("as_guild", true);
if (asGuild) payload.addProperty("as_guild", true);
operations.enqueue(player, "economy/market/buy", payload, Collections.emptyList(), null);
return;
}
@@ -93,3 +93,12 @@ integrations:
spark:
enabled: true
report-interval-seconds: 30
# ==============================================================================
# Client mod link
# Sends balance, level, guild and claim info to players who have the optional SCOPENET
# client mod (HUD widgets, claim overlay, shop and market windows). Players without it
# are unaffected. Display only: everything the client does is an ordinary command.
# ==============================================================================
clientlink:
enabled: true