feat(plugins): add Velocity proxy and Fabric/Forge/NeoForge/Paper integration mods
Server-side integration plugins: the Velocity proxy plugin plus Fabric, Forge, NeoForge, and Paper mods with a shared module. Gradle build output is not tracked.
This commit is contained in:
53 files changed
+4832
No files matched your search
@@ -0,0 +1,56 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'best.lolicon.felis'
|
||||
version = '0.1.0'
|
||||
|
||||
// Paper 1.21 runs on Java 21, and its API is published as a Java-21 artifact, so
|
||||
// this one module needs a Java-21 toolchain (the rest of the suite is 17). The
|
||||
// toolchain block makes that requirement explicit and self-enforcing: Gradle uses a
|
||||
// detected JDK 21 to compile, regardless of which JDK runs Gradle, and fails loudly
|
||||
// if none is present. The shared codec is plain Java-17 source, which 21 compiles
|
||||
// forward-compatibly.
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven {
|
||||
name = 'papermc'
|
||||
url = 'https://repo.papermc.io/repository/maven-public/'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// paper-api is compile-only (the server provides it at runtime). It pulls in
|
||||
// Bukkit + Adventure, which is all the lobby face needs.
|
||||
compileOnly 'io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT'
|
||||
}
|
||||
|
||||
// The lobby is a PURE UI face (spec §12): it speaks only the felis:control
|
||||
// plugin-message channel and never holds a felis-api token or talks to felis-api
|
||||
// directly. We enforce that physically here — the shared source root is on the
|
||||
// path, but the include filter ships ONLY the paper package and the three codec
|
||||
// files (Control + ControlFrame + Json). FelisApiClient, LinkClient and the token
|
||||
// config are not compiled in at all, so the lobby cannot reach the API even by
|
||||
// mistake. If a codec class grows a new dependency, compilation fails loudly here
|
||||
// rather than silently widening the lobby's reach.
|
||||
sourceSets {
|
||||
main {
|
||||
java {
|
||||
srcDir '../shared/src/main/java'
|
||||
include 'best/lolicon/felis/paper/**'
|
||||
include 'best/lolicon/felis/link/Control.java'
|
||||
include 'best/lolicon/felis/link/ControlFrame.java'
|
||||
include 'best/lolicon/felis/link/Json.java'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'felis-paper'
|
||||
@@ -0,0 +1,291 @@
|
||||
package best.lolicon.felis.paper;
|
||||
|
||||
import best.lolicon.felis.link.Control;
|
||||
import best.lolicon.felis.link.ControlFrame;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryDragEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.plugin.messaging.PluginMessageListener;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* FelisPaperPlugin is the felis-paper lobby face (spec §12): the {@code /menu} (and
|
||||
* {@code /server}) chest GUI players use to pick, wake or claim a backend without
|
||||
* ever touching the command line. It is the player-facing end of the §27 scenario-10
|
||||
* path — {@code /menu → plugin msg → velocity → api → 共用等待队列 → ready 后 Connect}.
|
||||
*
|
||||
* <p><b>Pure UI face.</b> This plugin deliberately holds no felis-api token, opens no
|
||||
* HTTP connection, and keeps no waiting queue. Every action it takes is a single
|
||||
* {@link ControlFrame} written to the {@code felis:control} plugin-message channel,
|
||||
* and every piece of state it shows arrives as a frame on the same channel. The
|
||||
* Velocity proxy ({@code ControlChannel}) is the only thing that talks to felis-api,
|
||||
* and it derives the acting player's identity from the backend connection rather than
|
||||
* anything this lobby sends (spec §14) — so even a fully compromised lobby cannot act
|
||||
* as another player or reach the API directly. The build enforces this physically:
|
||||
* only the channel codec ({@code Control}/{@code ControlFrame}/{@code Json}) is
|
||||
* compiled in from the shared core; {@code FelisApiClient} and the token config are
|
||||
* not on the lobby's classpath at all.
|
||||
*
|
||||
* <p><b>Flow.</b> Opening the menu paints a "loading" tile per configured server and
|
||||
* fires a {@code StatusQuery} for each; the proxy answers with {@code StatusUpdate}
|
||||
* frames that repaint each tile by phase + ownership. Clicking a tile sends a
|
||||
* {@code ClaimRequest} when it is claimable (ownerless + stopped → "Claim &
|
||||
* Start") or a {@code WakeRequest} otherwise (the single frame behind both the "Join"
|
||||
* of a running owned server and the "Wake" of a stopped owned one), then closes the
|
||||
* menu. A refusal comes back as an {@code Error} frame and is shown to the player —
|
||||
* the only place claim/quota/policy failures surface — and readiness arrives as
|
||||
* {@code TransferReady} just before the proxy Connects them.
|
||||
*/
|
||||
public final class FelisPaperPlugin extends JavaPlugin implements Listener, PluginMessageListener {
|
||||
|
||||
private static final Component MENU_TITLE =
|
||||
Component.text("Felis Servers", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false);
|
||||
private static final int MAX_TILES = 54; // a double chest, the GUI ceiling
|
||||
|
||||
/** Server names to show as tiles, in display order; loaded from config. */
|
||||
private final List<String> servers = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
saveDefaultConfig();
|
||||
servers.clear();
|
||||
servers.addAll(getConfig().getStringList("servers"));
|
||||
|
||||
// Open both ends of felis:control. Outgoing carries Wake/Claim/StatusQuery to
|
||||
// the proxy; incoming receives StatusUpdate/TransferReady/Error back.
|
||||
getServer().getMessenger().registerOutgoingPluginChannel(this, Control.CHANNEL);
|
||||
getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, this);
|
||||
getServer().getPluginManager().registerEvents(this, this);
|
||||
|
||||
getLogger().info("felis-paper enabled: " + servers.size()
|
||||
+ " server tile(s), felis:control open. Pure UI face — no felis-api token.");
|
||||
}
|
||||
|
||||
// ---- commands: /menu and /server both open the GUI ----
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player)) {
|
||||
sender.sendMessage(Component.text("Only a player can open the server menu.", NamedTextColor.RED));
|
||||
return true;
|
||||
}
|
||||
openMenu((Player) sender);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void openMenu(Player player) {
|
||||
if (servers.isEmpty()) {
|
||||
player.sendMessage(Component.text(
|
||||
"No servers are configured yet — ask an operator to set up felis-paper.",
|
||||
NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
int shown = Math.min(servers.size(), MAX_TILES);
|
||||
List<String> view = new ArrayList<>(servers.subList(0, shown));
|
||||
MenuHolder holder = new MenuHolder(view);
|
||||
Inventory inv = Bukkit.createInventory(holder, invSize(shown), MENU_TITLE);
|
||||
holder.setInventory(inv);
|
||||
for (int i = 0; i < shown; i++) {
|
||||
inv.setItem(i, loadingTile(view.get(i)));
|
||||
}
|
||||
player.openInventory(inv);
|
||||
// Ask the proxy for live status of every tile; answers repaint them.
|
||||
for (String server : view) {
|
||||
sendUpstream(player, ControlFrame.statusQuery(server));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- click: a tile is a button, never an item to pick up ----
|
||||
|
||||
@EventHandler
|
||||
public void onInventoryClick(InventoryClickEvent event) {
|
||||
Inventory top = event.getView().getTopInventory();
|
||||
if (!(top.getHolder() instanceof MenuHolder)) {
|
||||
return; // not our GUI
|
||||
}
|
||||
// Every slot in our GUI is a button: cancel unconditionally so nothing can be
|
||||
// taken out, even on clicks in empty slots or the player's own inventory.
|
||||
event.setCancelled(true);
|
||||
if (event.getClickedInventory() != top) {
|
||||
return; // click landed in the player's inventory, not a tile
|
||||
}
|
||||
if (!(event.getWhoClicked() instanceof Player)) {
|
||||
return;
|
||||
}
|
||||
Player player = (Player) event.getWhoClicked();
|
||||
MenuHolder holder = (MenuHolder) top.getHolder();
|
||||
int slot = event.getSlot();
|
||||
if (slot < 0 || slot >= holder.servers().size()) {
|
||||
return; // padding slot
|
||||
}
|
||||
String server = holder.servers().get(slot);
|
||||
ControlFrame state = holder.latest(server);
|
||||
if (state == null) {
|
||||
return; // still loading — no status yet, so we don't know which frame to send
|
||||
}
|
||||
// Claimable (ownerless + stopped) → Claim & Start; everything else → Wake
|
||||
// (which the proxy treats as Join when the owned server is already running).
|
||||
if (state.claimable()) {
|
||||
sendUpstream(player, ControlFrame.claimRequest(player.getName(), server));
|
||||
} else {
|
||||
sendUpstream(player, ControlFrame.wakeRequest(player.getName(), server));
|
||||
}
|
||||
player.closeInventory();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onInventoryDrag(InventoryDragEvent event) {
|
||||
// A drag can deposit into or sweep across our tiles without ever firing a
|
||||
// single InventoryClickEvent on them, so the click guard alone is not enough:
|
||||
// cancel any drag that touches our GUI so a tile can never be grabbed or smeared.
|
||||
if (event.getView().getTopInventory().getHolder() instanceof MenuHolder) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- downstream: felis:control frames from the proxy ----
|
||||
|
||||
@Override
|
||||
public void onPluginMessageReceived(String channel, Player player, byte[] message) {
|
||||
if (!Control.CHANNEL.equals(channel)) {
|
||||
return;
|
||||
}
|
||||
ControlFrame frame;
|
||||
try {
|
||||
frame = Control.decode(message);
|
||||
} catch (IllegalArgumentException e) {
|
||||
getLogger().fine("Dropping malformed felis:control frame: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
switch (frame.type()) {
|
||||
case ControlFrame.STATUS_UPDATE:
|
||||
applyStatus(player, frame);
|
||||
break;
|
||||
case ControlFrame.ERROR:
|
||||
// The proxy already sanitizes transport faults; this is the only place
|
||||
// a claim/quota/policy refusal becomes visible to the player.
|
||||
player.sendMessage(Component.text("⚠ " + errorText(frame), NamedTextColor.RED));
|
||||
break;
|
||||
case ControlFrame.TRANSFER_READY:
|
||||
// The proxy performs the actual Connect; just make sure a stale menu is
|
||||
// not left open over the join.
|
||||
closeIfMenu(player);
|
||||
break;
|
||||
default:
|
||||
// Upstream-only types (Wake/Claim/StatusQuery) are never expected back.
|
||||
}
|
||||
}
|
||||
|
||||
private void applyStatus(Player player, ControlFrame frame) {
|
||||
Inventory top = player.getOpenInventory().getTopInventory();
|
||||
if (!(top.getHolder() instanceof MenuHolder)) {
|
||||
return; // the player closed the menu before the answer arrived
|
||||
}
|
||||
MenuHolder holder = (MenuHolder) top.getHolder();
|
||||
int slot = holder.servers().indexOf(frame.server());
|
||||
if (slot < 0) {
|
||||
return; // a server we are not showing
|
||||
}
|
||||
holder.put(frame.server(), frame);
|
||||
top.setItem(slot, tile(frame));
|
||||
}
|
||||
|
||||
private void closeIfMenu(Player player) {
|
||||
if (player.getOpenInventory().getTopInventory().getHolder() instanceof MenuHolder) {
|
||||
player.closeInventory();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
|
||||
private ItemStack tile(ControlFrame f) {
|
||||
Material material;
|
||||
String action;
|
||||
NamedTextColor color;
|
||||
if (f.claimable()) {
|
||||
material = Material.GOLD_BLOCK;
|
||||
action = "Claim & Start";
|
||||
color = NamedTextColor.GOLD;
|
||||
} else if (f.ready()) {
|
||||
material = Material.LIME_CONCRETE;
|
||||
action = "Join";
|
||||
color = NamedTextColor.GREEN;
|
||||
} else {
|
||||
material = Material.RED_CONCRETE;
|
||||
action = "Wake";
|
||||
color = NamedTextColor.RED;
|
||||
}
|
||||
ItemStack item = new ItemStack(material);
|
||||
ItemMeta meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(action + " · " + f.server(), color)
|
||||
.decoration(TextDecoration.ITALIC, false));
|
||||
List<Component> lore = new ArrayList<>();
|
||||
lore.add(line("Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
|
||||
lore.add(line("Players", f.playersOnline() + "/" + f.playersMax()));
|
||||
meta.lore(lore);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private ItemStack loadingTile(String server) {
|
||||
ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
|
||||
ItemMeta meta = item.getItemMeta();
|
||||
meta.displayName(Component.text(server, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false));
|
||||
meta.lore(List.of(Component.text("Loading…", NamedTextColor.DARK_GRAY)
|
||||
.decoration(TextDecoration.ITALIC, false)));
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static Component line(String key, String value) {
|
||||
return Component.text(key + ": ", NamedTextColor.GRAY)
|
||||
.append(Component.text(value, NamedTextColor.WHITE))
|
||||
.decoration(TextDecoration.ITALIC, false);
|
||||
}
|
||||
|
||||
private static String errorText(ControlFrame f) {
|
||||
String code = f.code();
|
||||
if (code != null) {
|
||||
switch (code) {
|
||||
case "not_linked":
|
||||
return "Link your account first — run /link, then finish on the web panel.";
|
||||
case "quota_exceeded":
|
||||
return "You've reached your server quota.";
|
||||
case "already_claimed":
|
||||
return "That server was just claimed by someone else.";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return f.message() != null && !f.message().isEmpty()
|
||||
? f.message()
|
||||
: (code != null ? code : "Request failed — please try again.");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void sendUpstream(Player player, ControlFrame frame) {
|
||||
player.sendPluginMessage(this, Control.CHANNEL, Control.encode(frame));
|
||||
}
|
||||
|
||||
private static int invSize(int count) {
|
||||
int rows = Math.max(1, (count + 8) / 9);
|
||||
return Math.min(rows, 6) * 9;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package best.lolicon.felis.paper;
|
||||
|
||||
import best.lolicon.felis.link.ControlFrame;
|
||||
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MenuHolder is the identity and state carried by a {@code /menu} inventory. Bukkit
|
||||
* lets an {@link InventoryHolder} ride along with an {@link Inventory}, which is how
|
||||
* {@link FelisPaperPlugin} tells "this is our GUI" from any other open chest — a click
|
||||
* or a downstream frame only acts when {@code inventory.getHolder() instanceof
|
||||
* MenuHolder}. Tying the state to the inventory instance (rather than a per-player map
|
||||
* on the plugin) means it is garbage-collected with the menu and never leaks across
|
||||
* reopen.
|
||||
*
|
||||
* <p>It holds two things: the ordered list of server names (the list index <em>is</em>
|
||||
* the slot, so a click slot maps straight to a server) and the latest
|
||||
* {@link ControlFrame} seen for each, so a click knows whether to send a Claim or a
|
||||
* Wake without re-querying.
|
||||
*/
|
||||
final class MenuHolder implements InventoryHolder {
|
||||
|
||||
private final List<String> servers; // index = slot
|
||||
private final Map<String, ControlFrame> latest = new HashMap<>();
|
||||
private Inventory inventory;
|
||||
|
||||
MenuHolder(List<String> servers) {
|
||||
this.servers = servers;
|
||||
}
|
||||
|
||||
/** servers returns the tile order; the list index is the inventory slot. */
|
||||
List<String> servers() {
|
||||
return servers;
|
||||
}
|
||||
|
||||
/** latest is the most recent StatusUpdate for a server, or null if none yet. */
|
||||
ControlFrame latest(String server) {
|
||||
return latest.get(server);
|
||||
}
|
||||
|
||||
/** put records the latest StatusUpdate for a server. */
|
||||
void put(String server, ControlFrame frame) {
|
||||
latest.put(server, frame);
|
||||
}
|
||||
|
||||
void setInventory(Inventory inventory) {
|
||||
this.inventory = inventory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Inventory getInventory() {
|
||||
return inventory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# felis-paper — the lobby UI face (spec §12).
|
||||
#
|
||||
# This plugin is a PURE UI face: it speaks only the felis:control plugin-message
|
||||
# channel to the Velocity proxy. It holds no felis-api token and never contacts
|
||||
# felis-api directly. The list below is only which server tiles to show in the
|
||||
# /menu GUI; the proxy (and felis-api behind it) stay the source of truth for
|
||||
# status, ownership and autostart — every tile is filled in by a live StatusQuery
|
||||
# over felis:control when the menu opens, and acting on a tile sends a Wake or
|
||||
# Claim frame that the proxy authorizes against the player's verified identity.
|
||||
#
|
||||
# Replace the examples below with the names of your felis servers (the CRD
|
||||
# metadata.name / the server's felis name, not its display title). Up to 54 are
|
||||
# shown. An empty list makes /menu say there is nothing to show.
|
||||
servers:
|
||||
- smp
|
||||
- creative
|
||||
@@ -0,0 +1,13 @@
|
||||
name: FelisPaper
|
||||
version: 0.1.0
|
||||
main: best.lolicon.felis.paper.FelisPaperPlugin
|
||||
api-version: '1.21'
|
||||
authors: [Felis]
|
||||
description: Lobby UI face — /menu and /server open a chest GUI that drives the felis:control channel.
|
||||
commands:
|
||||
menu:
|
||||
description: Open the Felis server menu.
|
||||
usage: /menu
|
||||
server:
|
||||
description: Open the Felis server menu (alias of /menu).
|
||||
usage: /server
|
||||
Reference in new issue
Block a user