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:
flyemoji committed 2026-06-26 23:32:40 +09:00
1 parent eee00c2772
commit 93f143f5b6
53 files changed
+4832

No files matched your search

+56
View File
@@ -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'
}
+1
View File
@@ -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 &amp;
* 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