fix(lobby): 大厅变成安全的中转大厅并提示 /menu,大厅上限 200、登录门不限人数

This commit is contained in:
Lemon-miaow committed 2026-09-26 23:11:30 +08:00
1 parent fda14e9e45
commit 4425060d3e
12 files changed
+996 -4

No files matched your search

+4
View File
@@ -116,6 +116,10 @@ LOOHP/Limbo would otherwise default to `30000`, unreachable through the Velocity
idempotent, so a persisted world volume keeps all its other `server.properties`
settings. Do **not** override `FELIS_GAME_PORT` except in lockstep with the operator.
It also pins `max-players=-1` (no cap, Limbo's own default): unbound players wait at
the gate for up to ten minutes and a stopped server's players all fall back here at
once, so a cap left on the volume would turn players away at the door.
## Configure (deployer's responsibility)
One setting this image does **not** guess (it keeps the release's own default):
+10 -1
View File
@@ -61,7 +61,11 @@ set_prop() {
# The secret is base64/hex-ish, but a '/' or '&' would still break a bare sed s///.
# '|' as the delimiter plus escaping it is enough for every value we write.
esc=$(printf '%s' "$2" | sed 's/[|\\&]/\\&/g')
sed -i "s|^$1=.*|$1=${esc}|" "$PROPS"
# Through a temp file rather than sed -i, which BSD sed reads differently, so the
# same function runs under the entrypoint tests on any machine.
sed "s|^$1=.*|$1=${esc}|" "$PROPS" > "$PROPS.tmp"
cat "$PROPS.tmp" > "$PROPS"
rm -f "$PROPS.tmp"
else
printf '%s=%s\n' "$1" "$2" >> "$PROPS"
fi
@@ -75,6 +79,11 @@ set_prop bungeecord false
set_prop bungee-guard false
set_prop velocity-modern true
set_prop forwarding-secrets "$SECRET"
# Unbound players wait here for up to ten minutes, and a stopped server's players all
# fall back here at once, so the gate must never be full. -1 (no cap) is Limbo's own
# default; it is pinned so a hand-edited properties file on the volume cannot bring
# a cap back.
set_prop max-players -1
echo "felis-limbo: server-port=${PORT}, velocity-modern=true (forwarding secret loaded, UUIDs are Mojang-verified)"
JAVA_MEMORY_ARG=""
+22
View File
@@ -46,3 +46,25 @@ sudo felis setup
- Align `online-mode` / player forwarding with the off-cluster Velocity proxy.
- The lobby speaks only the `felis:control` plugin-message channel; it holds no
felis-api token by design (spec §12).
## What the lobby allows
felis-paper's `LobbyGuard` keeps the lobby a hub that nobody can hurt, get hurt in,
or leave a mark on:
- every world is peaceful, with natural spawning, PvP, mob griefing and TNT off,
time frozen at noon, clear weather and inventories kept;
- players take no damage and never go hungry; a fall into the void lands at spawn;
- a player without `felis.lobby.build` joins at spawn in adventure mode and cannot
break or place blocks, use buckets, trample farmland, light fires, or harm mobs,
item frames, paintings, armor stands or vehicles. Buttons, doors, pressure plates
and containers keep working;
- every join gets a chat line with a click that runs `/menu`.
`felis.lobby.build` defaults to ops. To let an admin build the lobby, grant it with
LuckPerms (`lp user <name> permission set felis.lobby.build true` on the lobby console)
or op them.
The entrypoint pins `max-players=200` on every boot, over Paper's default of 20: every
authenticated player passes through here, and a stopped server's players arrive all at
once.
+13 -1
View File
@@ -57,7 +57,11 @@ set_prop() {
# otherwise corrupt this bare sed s||| and silently break the key. Same escaping as
# deploy/limbo — without it an unlucky password kills the console/permission channel.
esc=$(printf '%s' "$2" | sed 's/[|\\&]/\\&/g')
sed -i "s|^$1=.*|$1=${esc}|" "$PROPS"
# Through a temp file rather than sed -i, which BSD sed reads differently, so the
# same function runs under the entrypoint tests on any machine.
sed "s|^$1=.*|$1=${esc}|" "$PROPS" > "$PROPS.tmp"
cat "$PROPS.tmp" > "$PROPS"
rm -f "$PROPS.tmp"
else
printf '%s=%s\n' "$1" "$2" >> "$PROPS"
fi
@@ -66,6 +70,14 @@ set_prop() {
set_prop server-port "$PORT"
set_prop online-mode false
# Every authenticated player passes through the lobby, and a stopped server's players
# arrive together (they fall back to the login gate, which sends them straight on).
# Paper's default cap of 20 would turn the 21st away at the door. 200 is far above
# what one node serves at once, and a flood beyond it is refused at the door instead
# of running the 1Gi lobby out of memory. What the world itself allows (no damage, no
# building, the /menu hint) is felis-paper's LobbyGuard.
set_prop max-players 200
# RCON is the control plane's write channel (spec §8 写=RCON): the operator probes it
# for readiness and the player tally, and felis-api runs console/permission commands over
# it. Paper only reads these three keys from server.properties, so the operator's injected
+133
View File
@@ -0,0 +1,133 @@
package felis
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// The lobby and login gate take their player cap from server.properties, which the
// entrypoint rewrites on every boot over whatever the volume already holds. These run
// the shipped entrypoints the way a pod does (image and volume paths pointed into temp
// dirs, java replaced by a stub that exits) and read the file the server would start on.
// runEntrypoint runs the embedded entrypoint with its runtime dir holding the given
// files and server.properties seeded with props ("" for a first boot), and returns
// server.properties afterwards.
func runEntrypoint(t *testing.T, name, runtimeVar string, files []string, props string, env ...string) string {
t.Helper()
root := t.TempDir()
runtime, data, bin := filepath.Join(root, "image"), filepath.Join(root, "data"), filepath.Join(root, "bin")
for _, f := range files {
p := filepath.Join(runtime, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("jar"), 0o644); err != nil {
t.Fatal(err)
}
}
for _, d := range []string{data, bin} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if props != "" {
if err := os.WriteFile(filepath.Join(data, "server.properties"), []byte(props), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(bin, "java"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
script := readGameStackFile(t, name)
for old, repl := range map[string]string{
runtimeVar: `RUNTIME_DIR="` + runtime + `"`,
`DATA_DIR="/data"`: `DATA_DIR="` + data + `"`,
} {
if !strings.Contains(script, old) {
t.Fatalf("%s no longer sets %s", name, old)
}
script = strings.Replace(script, old, repl, 1)
}
path := filepath.Join(root, "entrypoint.sh")
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("sh", path)
cmd.Env = append([]string{"PATH=" + bin + ":" + os.Getenv("PATH"), "FELIS_FORWARDING_SECRET=fwd-test"}, env...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s failed: %v\n%s", name, err, out)
}
got, err := os.ReadFile(filepath.Join(data, "server.properties"))
if err != nil {
t.Fatal(err)
}
return string(got)
}
// propLines lists the key's lines, so a key written twice shows up as two.
func propLines(props, key string) []string {
var out []string
for _, line := range strings.Split(props, "\n") {
if strings.HasPrefix(line, key+"=") {
out = append(out, line)
}
}
return out
}
func assertProp(t *testing.T, props, key, want string) {
t.Helper()
got := propLines(props, key)
if len(got) != 1 || got[0] != key+"="+want {
t.Errorf("%s: got %q, want exactly [%s=%s]\nserver.properties:\n%s", key, got, key, want, props)
}
}
var lobbyImage = []string{"paper.jar", "plugins/felis-paper.jar", "plugins/LuckPerms.jar"}
func TestLobbyEntrypointLiftsThePlayerCap(t *testing.T) {
t.Run("over the cap Paper wrote on an earlier boot", func(t *testing.T) {
props := runEntrypoint(t, "deploy/lobby/entrypoint.sh", `RUNTIME_DIR="/paper"`, lobbyImage,
"#Minecraft server properties\nmax-players=20\nmotd=Kept as it was\n")
assertProp(t, props, "max-players", "200")
assertProp(t, props, "motd", "Kept as it was")
})
t.Run("on a first boot", func(t *testing.T) {
props := runEntrypoint(t, "deploy/lobby/entrypoint.sh", `RUNTIME_DIR="/paper"`, lobbyImage, "")
assertProp(t, props, "max-players", "200")
assertProp(t, props, "online-mode", "false")
})
}
// The RCON password is arbitrary bytes from a Secret; each of sed's special characters
// has to land in the file as itself when an earlier boot's line is replaced.
func TestLobbyEntrypointWritesTheRconPasswordVerbatim(t *testing.T) {
const password = `a|b\c&d/e`
props := runEntrypoint(t, "deploy/lobby/entrypoint.sh", `RUNTIME_DIR="/paper"`, lobbyImage,
"enable-rcon=false\nrcon.password=stale\n", "RCON_PASSWORD="+password)
assertProp(t, props, "enable-rcon", "true")
assertProp(t, props, "rcon.password", password)
}
var limboImage = []string{"Limbo.jar", "plugins/felis-limbo.jar"}
func TestLimboEntrypointNeverCapsTheGate(t *testing.T) {
t.Run("over a cap left on the volume", func(t *testing.T) {
props := runEntrypoint(t, "deploy/limbo/entrypoint.sh", `RUNTIME_DIR="/limbo"`, limboImage,
"max-players=10\nlevel-name=world;spawn.schem\n")
assertProp(t, props, "max-players", "-1")
assertProp(t, props, "level-name", "world;spawn.schem")
assertProp(t, props, "velocity-modern", "true")
})
t.Run("on a first boot", func(t *testing.T) {
props := runEntrypoint(t, "deploy/limbo/entrypoint.sh", `RUNTIME_DIR="/limbo"`, limboImage, "")
assertProp(t, props, "max-players", "-1")
assertProp(t, props, "forwarding-secrets", "fwd-test")
})
}
+22
View File
@@ -60,3 +60,25 @@ sourceSets {
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
// LobbyGuardTest drives the real LobbyGuard handlers with real paper-api events around
// Proxy-built fakes (plain main, no framework, like the velocity routing tests). It is
// not part of `build`, which the lobby image runs; plugins/test.sh runs `./gradlew lobbyTest`.
sourceSets {
lobbyTest {
java {
srcDir 'test'
include 'best/lolicon/felis/paper/Fakes.java'
include 'best/lolicon/felis/paper/LobbyGuardTest.java'
}
compileClasspath += sourceSets.main.output + configurations.compileClasspath
runtimeClasspath += output + compileClasspath
}
}
tasks.register('lobbyTest', JavaExec) {
group = 'verification'
description = 'Runs the LobbyGuardTest self-test main.'
classpath = sourceSets.lobbyTest.runtimeClasspath
mainClass = 'best.lolicon.felis.paper.LobbyGuardTest'
}
@@ -85,6 +85,12 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, this);
getServer().getPluginManager().registerEvents(this, this);
// The lobby is a hub nobody can hurt or be hurt in (LobbyGuard). Worlds loaded
// before this point get the rules here, later ones on their WorldLoadEvent.
LobbyGuard guard = new LobbyGuard(getLogger());
getServer().getPluginManager().registerEvents(guard, this);
getServer().getWorlds().forEach(guard::protect);
getLogger().info("felis-paper enabled: felis:control open, server list from the proxy. "
+ "Pure UI face — no felis-api token.");
}
@@ -406,7 +412,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
// ---- helpers ----
/** zh mirrors the Velocity rule: render Chinese when the client locale is zh-*. */
private static boolean zh(Player player) {
static boolean zh(Player player) {
return "zh".equalsIgnoreCase(player.locale().getLanguage());
}
@@ -0,0 +1,267 @@
package best.lolicon.felis.paper;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Difficulty;
import org.bukkit.GameMode;
import org.bukkit.GameRules;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Entity;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockBurnEvent;
import org.bukkit.event.block.BlockIgniteEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.FoodLevelChangeEvent;
import org.bukkit.event.hanging.HangingBreakByEntityEvent;
import org.bukkit.event.player.PlayerArmorStandManipulateEvent;
import org.bukkit.event.player.PlayerBucketEmptyEvent;
import org.bukkit.event.player.PlayerBucketFillEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.vehicle.VehicleDestroyEvent;
import org.bukkit.event.world.WorldLoadEvent;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* LobbyGuard keeps the lobby a hub: a place every authenticated player passes through
* on the way to a server, which nobody can hurt, get hurt in, or leave a mark on.
* Without it the lobby is a plain survival world: mobs at night, PvP, and every block
* broken or placed by a passer-by stays in the world volume for the next player.
*
* <p><b>World.</b> Every world is made peaceful with natural spawning, PvP, mob
* griefing and TNT off, time frozen at noon and the weather clear, and inventories
* kept. These are world rules (level.dat), so they are set here on enable and on
* every world load rather than in server.properties.
*
* <p><b>Players.</b> A player takes no damage and never goes hungry; one who falls out
* of the world is put back at spawn. Without {@link #BUILD_PERMISSION} (ops have it by
* default) a player joins in adventure mode at spawn and cannot break or place
* blocks, pour or scoop liquids, trample farmland, light fires, or harm any entity:
* mobs, item frames, paintings, armor stands and vehicles alike. Buttons, doors,
* pressure plates and containers keep working, so a lobby an admin builds with
* them still works for everyone.
*
* <p><b>Onboarding.</b> Every join gets one chat line saying what the lobby is for,
* with a click that runs {@code /menu}: the only way on from here, and one nobody
* would otherwise guess.
*/
final class LobbyGuard implements Listener {
/** Lets a player build in the lobby (default: ops). */
static final String BUILD_PERMISSION = "felis.lobby.build";
/** Noon: the lobby is always lit. */
private static final long NOON = 6000L;
static boolean guarded(Player player) {
return !player.hasPermission(BUILD_PERMISSION);
}
private final Logger log;
LobbyGuard(Logger log) {
this.log = log;
}
/**
* protect applies the world rules and logs what it could not set. The lobby's job is
* the menu, so a rule a server version refuses (or no longer has) costs the rule,
* never the plugin.
*/
void protect(World world) {
try {
applyRules(world);
} catch (RuntimeException | LinkageError e) {
log.log(Level.WARNING, "could not apply every lobby rule to world " + world.getName()
+ "; the menu is unaffected", e);
}
}
static void applyRules(World world) {
world.setDifficulty(Difficulty.PEACEFUL);
// Only a world with its own clock has a time of day to set; the nether and the
// end have none and refuse.
if (!world.isFixedTime()) {
world.setTime(NOON);
}
world.setStorm(false);
world.setThundering(false);
world.setGameRule(GameRules.PVP, false);
world.setGameRule(GameRules.SPAWN_MOBS, false);
world.setGameRule(GameRules.SPAWN_WANDERING_TRADERS, false);
world.setGameRule(GameRules.MOB_GRIEFING, false);
world.setGameRule(GameRules.TNT_EXPLODES, false);
world.setGameRule(GameRules.KEEP_INVENTORY, true);
world.setGameRule(GameRules.IMMEDIATE_RESPAWN, true);
world.setGameRule(GameRules.SHOW_ADVANCEMENT_MESSAGES, false);
world.setGameRule(GameRules.ADVANCE_TIME, false);
world.setGameRule(GameRules.ADVANCE_WEATHER, false);
}
@EventHandler
public void onWorldLoad(WorldLoadEvent event) {
protect(event.getWorld());
}
// ---- joining ----
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
player.setFoodLevel(20);
if (guarded(player)) {
player.setGameMode(GameMode.ADVENTURE);
player.teleport(player.getWorld().getSpawnLocation());
}
player.sendMessage(hint(FelisPaperPlugin.zh(player)));
}
static Component hint(boolean zh) {
Component open = Component.text(zh ? "[打开服务器菜单]" : "[Open the server menu]",
NamedTextColor.GREEN, TextDecoration.BOLD)
.clickEvent(ClickEvent.runCommand("/menu"))
.hoverEvent(HoverEvent.showText(Component.text(zh ? "点击运行 /menu" : "Click to run /menu")));
return Component.text(zh ? "欢迎来到大厅。输入 /menu 或点击 " : "Welcome to the lobby. Type /menu or click ",
NamedTextColor.GOLD)
.append(open)
.append(Component.text(zh ? ",选一个服务器进入。" : " to pick a server to join.",
NamedTextColor.GOLD));
}
// ---- nobody gets hurt ----
@EventHandler(priority = EventPriority.LOW)
public void onDamage(EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player player)) {
return;
}
event.setCancelled(true);
if (event.getCause() == EntityDamageEvent.DamageCause.VOID) {
player.teleport(player.getWorld().getSpawnLocation());
}
}
@EventHandler(priority = EventPriority.LOW)
public void onHunger(FoodLevelChangeEvent event) {
if (event.getEntity() instanceof Player) {
event.setCancelled(true);
}
}
// ---- nobody leaves a mark ----
@EventHandler(priority = EventPriority.LOW)
public void onBreak(BlockBreakEvent event) {
if (guarded(event.getPlayer())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.LOW)
public void onPlace(BlockPlaceEvent event) {
if (guarded(event.getPlayer())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.LOW)
public void onPour(PlayerBucketEmptyEvent event) {
if (guarded(event.getPlayer())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.LOW)
public void onScoop(PlayerBucketFillEvent event) {
if (guarded(event.getPlayer())) {
event.setCancelled(true);
}
}
/** Stepping on farmland or turtle eggs destroys them; pressure plates are left alone. */
@EventHandler(priority = EventPriority.LOW)
public void onTrample(PlayerInteractEvent event) {
Block block = event.getClickedBlock();
if (event.getAction() != Action.PHYSICAL || block == null || !guarded(event.getPlayer())) {
return;
}
Material type = block.getType();
if (type == Material.FARMLAND || type == Material.TURTLE_EGG) {
event.setCancelled(true);
}
}
/** Fire spreads and burns with nobody behind it, so only a builder may start one. */
@EventHandler(priority = EventPriority.LOW)
public void onIgnite(BlockIgniteEvent event) {
if (!(event.getIgnitingEntity() instanceof Player player) || guarded(player)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.LOW)
public void onBurn(BlockBurnEvent event) {
event.setCancelled(true);
}
@EventHandler(priority = EventPriority.LOW)
public void onHit(EntityDamageByEntityEvent event) {
if (guardedCulprit(event.getDamager())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.LOW)
public void onHangingBreak(HangingBreakByEntityEvent event) {
if (guardedCulprit(event.getRemover())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.LOW)
public void onVehicleDestroy(VehicleDestroyEvent event) {
if (guardedCulprit(event.getAttacker())) {
event.setCancelled(true);
}
}
/** Right-clicking an item frame takes or turns what it holds. */
@EventHandler(priority = EventPriority.LOW)
public void onFrame(PlayerInteractEntityEvent event) {
if (event.getRightClicked() instanceof ItemFrame && guarded(event.getPlayer())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.LOW)
public void onArmorStand(PlayerArmorStandManipulateEvent event) {
if (guarded(event.getPlayer())) {
event.setCancelled(true);
}
}
/** guardedCulprit: the entity is a guarded player, or something one of them shot. */
private static boolean guardedCulprit(Entity culprit) {
if (culprit instanceof Projectile projectile && projectile.getShooter() instanceof Player shooter) {
return guarded(shooter);
}
return culprit instanceof Player player && guarded(player);
}
}
+5 -1
View File
@@ -3,7 +3,7 @@ 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.
description: Lobby UI face — /menu and /server open a chest GUI that drives the felis:control channel; the lobby world is kept safe and unchanged.
commands:
menu:
description: Open the Felis server menu.
@@ -11,3 +11,7 @@ commands:
server:
description: Open the Felis server menu (alias of /menu).
usage: /server
permissions:
felis.lobby.build:
description: Build in the lobby. Everyone else joins in adventure mode and cannot change the world.
default: op
@@ -0,0 +1,178 @@
package best.lolicon.felis.paper;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TextComponent;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Fakes are the paper-api interfaces LobbyGuardTest drives the real listener with,
* built with {@link Proxy}. They answer what the guard reads (a permission, a
* world, its spawn, a block's type, a shooter) and record what it does to a player.
*/
final class Fakes {
private Fakes() {
}
/** Returned by an {@link Answers} for a method it does not answer. */
static final Object UNANSWERED = new Object();
interface Answers {
Object answer(String method, Object[] args) throws Throwable;
}
/**
* fake builds an instance of an interface. Methods the answers leave unanswered
* return a zero value; the guard reads nothing it is not told.
*/
static <T> T fake(Class<T> type, Answers answers) {
InvocationHandler h = (proxy, m, args) -> {
Object[] a = args == null ? new Object[0] : args;
if (m.getDeclaringClass() == Object.class) {
switch (m.getName()) {
case "equals":
return proxy == a[0];
case "hashCode":
return System.identityHashCode(proxy);
default:
return type.getSimpleName() + "@fake";
}
}
Object r = answers.answer(m.getName(), a);
return r != UNANSWERED ? r : zero(m.getReturnType());
};
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, h));
}
/** bare is an entity of the given type that answers nothing. */
static <T> T bare(Class<T> type) {
return fake(type, (m, a) -> UNANSWERED);
}
private static Object zero(Class<?> t) {
if (t == boolean.class) {
return false;
}
if (t == int.class || t == short.class || t == byte.class) {
return 0;
}
if (t == long.class) {
return 0L;
}
if (t == double.class || t == float.class) {
return 0.0;
}
return null;
}
/**
* A world whose spawn is a fixed location, and what was set on it. A clockless world
* (the nether, the end) says so through isFixedTime; refusesTime makes setTime throw
* the way Paper does for one.
*/
static final class FakeWorld {
final World world;
final Location spawn;
final List<String> calls = new ArrayList<>();
FakeWorld() {
this("world", false, false);
}
FakeWorld(String name, boolean fixedTime, boolean refusesTime) {
Location[] at = new Location[1];
world = fake(World.class, (m, a) -> {
switch (m) {
case "getSpawnLocation":
return at[0].clone();
case "getName":
return name;
case "isFixedTime":
return fixedTime;
case "setTime":
if (refusesTime) {
throw new IllegalArgumentException("Cannot set time in world without world clock");
}
calls.add("time " + a[0]);
return null;
case "setDifficulty":
case "setStorm":
case "setThundering":
calls.add(m + " " + a[0]);
return null;
default:
return UNANSWERED;
}
});
at[0] = new Location(world, 8.5, 70, -3.5);
spawn = at[0];
}
}
/** A player who either holds the lobby build permission or not, and what was done to them. */
static final class FakePlayer {
final Player player;
final List<Location> teleports = new ArrayList<>();
final List<Component> messages = new ArrayList<>();
GameMode gameMode = GameMode.SURVIVAL;
int food = 3;
FakePlayer(boolean builder, FakeWorld world, Locale locale) {
player = fake(Player.class, (m, a) -> {
switch (m) {
case "hasPermission":
return builder && LobbyGuard.BUILD_PERMISSION.equals(a[0]);
case "getWorld":
return world.world;
case "locale":
return locale;
case "teleport":
teleports.add((Location) a[0]);
return true;
case "setGameMode":
gameMode = (GameMode) a[0];
return null;
case "getGameMode":
return gameMode;
case "setFoodLevel":
food = (Integer) a[0];
return null;
case "sendMessage":
if (a.length > 0 && a[0] instanceof Component c) {
messages.add(c);
return null;
}
return UNANSWERED;
default:
return UNANSWERED;
}
});
}
}
static Block block(Material type) {
return fake(Block.class, (m, a) -> m.equals("getType") ? type : UNANSWERED);
}
/** text is the plain text of a component built with Component.text. */
static String text(Component c) {
StringBuilder sb = new StringBuilder();
if (c instanceof TextComponent t) {
sb.append(t.content());
}
for (Component child : c.children()) {
sb.append(text(child));
}
return sb.toString();
}
}
@@ -0,0 +1,325 @@
package best.lolicon.felis.paper;
import best.lolicon.felis.paper.Fakes.FakePlayer;
import best.lolicon.felis.paper.Fakes.FakeWorld;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import org.bukkit.GameMode;
import org.bukkit.Material;
import org.bukkit.block.BlockFace;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.Cow;
import org.bukkit.entity.Entity;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Minecart;
import org.bukkit.entity.Painting;
import org.bukkit.entity.Player;
import org.bukkit.entity.Villager;
import org.bukkit.event.Cancellable;
import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockBurnEvent;
import org.bukkit.event.block.BlockIgniteEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.bukkit.event.entity.FoodLevelChangeEvent;
import org.bukkit.event.hanging.HangingBreakByEntityEvent;
import org.bukkit.event.player.PlayerArmorStandManipulateEvent;
import org.bukkit.event.player.PlayerBucketEmptyEvent;
import org.bukkit.event.player.PlayerBucketFillEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.vehicle.VehicleDestroyEvent;
import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.inventory.EquipmentSlot;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
/**
* LobbyGuardTest drives the real LobbyGuard handlers with real paper-api events built
* around fake players, worlds and entities: what a passer-by may not do to the lobby,
* that a builder may, that nobody is hurt or starved and a fall into the void lands at
* spawn, and what a player is told and set to on joining. Framework free: a failed
* assertion throws.
*
* <p>The game rules resolve through the server's registry, which a fake cannot stand in
* for; they are checked on a real Paper server. Here, the registry failing to load is
* what a rule the server refuses looks like, and the guard has to log it and carry on.
*
* <p>Run: {@code ./gradlew lobbyTest} in plugins/paper.
*/
// The paper-api event constructors are marked for removal (the server builds them);
// a test raising the events itself has no other way in.
@SuppressWarnings("removal")
public final class LobbyGuardTest {
private static final List<LogRecord> WARNINGS = new ArrayList<>();
private static final LobbyGuard GUARD = new LobbyGuard(capturingLogger());
private static final DamageSource HIT = Fakes.bare(DamageSource.class);
private static int checks;
private static FakeWorld world;
public static void main(String[] args) {
world = new FakeWorld();
passerByLeavesNoMark();
builderMayBuild();
mechanismsStillWork();
fire();
nobodyIsHurt();
joining();
worldRules();
System.out.println("LobbyGuardTest OK (" + checks + " checks)");
}
/** Every way a player could change the world, as the event a guarded or builder player raises. */
private static List<Case> marks() {
List<Case> cases = new ArrayList<>();
cases.add(new Case("break a block", p -> new BlockBreakEvent(Fakes.block(Material.STONE), p)));
cases.add(new Case("place a block", p -> new BlockPlaceEvent(Fakes.block(Material.DIRT), null,
Fakes.block(Material.STONE), null, p, true, EquipmentSlot.HAND)));
cases.add(new Case("pour a bucket", p -> new PlayerBucketEmptyEvent(p, Fakes.block(Material.AIR),
Fakes.block(Material.STONE), BlockFace.UP, Material.LAVA_BUCKET, null, EquipmentSlot.HAND)));
cases.add(new Case("scoop a bucket", p -> new PlayerBucketFillEvent(p, Fakes.block(Material.WATER),
Fakes.block(Material.STONE), BlockFace.UP, Material.BUCKET, null, EquipmentSlot.HAND)));
cases.add(new Case("trample farmland", p -> interact(p, Action.PHYSICAL, Material.FARMLAND)));
cases.add(new Case("trample turtle eggs", p -> interact(p, Action.PHYSICAL, Material.TURTLE_EGG)));
cases.add(new Case("hit a cow", p -> hit(p, Fakes.bare(Cow.class))));
cases.add(new Case("hit a villager", p -> hit(p, Fakes.bare(Villager.class))));
cases.add(new Case("shoot an armor stand", p -> hit(arrowFrom(p), Fakes.bare(ArmorStand.class))));
cases.add(new Case("pop an item frame", p -> hit(p, Fakes.bare(ItemFrame.class))));
cases.add(new Case("break a painting", p -> new HangingBreakByEntityEvent(Fakes.bare(Painting.class), p, HIT)));
cases.add(new Case("shoot a painting down",
p -> new HangingBreakByEntityEvent(Fakes.bare(Painting.class), arrowFrom(p), HIT)));
cases.add(new Case("break a minecart", p -> new VehicleDestroyEvent(Fakes.bare(Minecart.class), HIT, p)));
cases.add(new Case("turn an item frame", p -> new PlayerInteractEntityEvent(p, Fakes.bare(ItemFrame.class))));
cases.add(new Case("strip an armor stand", p -> new PlayerArmorStandManipulateEvent(p,
Fakes.bare(ArmorStand.class), null, null, EquipmentSlot.HEAD, EquipmentSlot.HAND)));
cases.add(new Case("light a fire", p -> new BlockIgniteEvent(Fakes.block(Material.GRASS_BLOCK),
BlockIgniteEvent.IgniteCause.FLINT_AND_STEEL, p)));
return cases;
}
private static void passerByLeavesNoMark() {
Player p = new FakePlayer(false, world, Locale.US).player;
for (Case c : marks()) {
assertEq("a passer-by cannot " + c.what, true, c.fire(p));
}
}
private static void builderMayBuild() {
Player p = new FakePlayer(true, world, Locale.US).player;
for (Case c : marks()) {
assertEq("a builder can " + c.what, false, c.fire(p));
}
}
private static void mechanismsStillWork() {
Player p = new FakePlayer(false, world, Locale.US).player;
assertEq("a pressure plate still works", false,
dispatch(interact(p, Action.PHYSICAL, Material.STONE_PRESSURE_PLATE)));
assertEq("a button still works", false,
dispatch(interact(p, Action.RIGHT_CLICK_BLOCK, Material.STONE_BUTTON)));
assertEq("a chest still opens", false, dispatch(interact(p, Action.RIGHT_CLICK_BLOCK, Material.CHEST)));
assertEq("talking to a villager still works", false,
dispatch(new PlayerInteractEntityEvent(p, Fakes.bare(Villager.class))));
}
private static void fire() {
assertEq("fire spreading on its own is stopped", true, dispatch(new BlockIgniteEvent(
Fakes.block(Material.OAK_PLANKS), BlockIgniteEvent.IgniteCause.SPREAD, (Entity) null)));
assertEq("lightning starts no fire", true, dispatch(new BlockIgniteEvent(
Fakes.block(Material.GRASS_BLOCK), BlockIgniteEvent.IgniteCause.LIGHTNING, (Entity) null)));
assertEq("fire burns nothing away", true, dispatch(new BlockBurnEvent(Fakes.block(Material.OAK_LOG), null)));
}
private static void nobodyIsHurt() {
FakePlayer builder = new FakePlayer(true, world, Locale.US);
for (DamageCause cause : new DamageCause[]{DamageCause.FALL, DamageCause.LAVA, DamageCause.DROWNING,
DamageCause.ENTITY_ATTACK, DamageCause.STARVATION}) {
FakePlayer p = new FakePlayer(false, world, Locale.US);
assertEq("a player takes no " + cause + " damage", true,
dispatch(new EntityDamageEvent(p.player, cause, HIT, 4)));
assertEq("... and stays where they are", 0, p.teleports.size());
}
assertEq("a builder takes no damage either", true,
dispatch(new EntityDamageEvent(builder.player, DamageCause.FALL, HIT, 4)));
FakePlayer other = new FakePlayer(false, world, Locale.US);
assertEq("a player cannot hurt another", true,
dispatch(new EntityDamageByEntityEvent(other.player, builder.player, DamageCause.ENTITY_ATTACK, HIT, 4)));
FakePlayer faller = new FakePlayer(true, world, Locale.US);
assertEq("falling out of the world does no damage", true,
dispatch(new EntityDamageEvent(faller.player, DamageCause.VOID, HIT, 4)));
assertEq("... and lands the player at spawn", List.of(world.spawn), faller.teleports);
Cow cow = Fakes.bare(Cow.class);
assertEq("a mob is not made immortal", false, dispatch(new EntityDamageEvent(cow, DamageCause.FALL, HIT, 4)));
assertEq("nobody goes hungry", true,
dispatch(new FoodLevelChangeEvent(new FakePlayer(false, world, Locale.US).player, 2)));
}
private static void joining() {
FakePlayer p = new FakePlayer(false, world, Locale.US);
GUARD.onJoin(new PlayerJoinEvent(p.player, Component.text("joined")));
assertEq("a passer-by joins in adventure mode", GameMode.ADVENTURE, p.gameMode);
assertEq("... at spawn", List.of(world.spawn), p.teleports);
assertEq("... fed", 20, p.food);
assertEq("... and told about the menu once", 1, p.messages.size());
Component hint = p.messages.get(0);
assertEq("the hint names /menu", true, Fakes.text(hint).contains("Type /menu"));
assertEq("the hint runs /menu when clicked", List.of("/menu"), commands(hint));
FakePlayer builder = new FakePlayer(true, world, Locale.US);
builder.gameMode = GameMode.CREATIVE;
GUARD.onJoin(new PlayerJoinEvent(builder.player, Component.text("joined")));
assertEq("a builder keeps their game mode", GameMode.CREATIVE, builder.gameMode);
assertEq("... and their place", 0, builder.teleports.size());
assertEq("... and is told about the menu too", List.of("/menu"), commands(builder.messages.get(0)));
FakePlayer zh = new FakePlayer(false, world, Locale.SIMPLIFIED_CHINESE);
GUARD.onJoin(new PlayerJoinEvent(zh.player, Component.text("joined")));
assertEq("a Chinese client is told in Chinese", true, Fakes.text(zh.messages.get(0)).contains("输入 /menu"));
assertEq("... with the same click", List.of("/menu"), commands(zh.messages.get(0)));
}
private static void worldRules() {
FakeWorld overworld = new FakeWorld("world", false, false);
WARNINGS.clear();
GUARD.onWorldLoad(new WorldLoadEvent(overworld.world));
assertEq("a loaded world is made peaceful, noon and clear",
List.of("setDifficulty PEACEFUL", "time 6000", "setStorm false", "setThundering false"),
overworld.calls);
FakeWorld nether = new FakeWorld("world_nether", true, true);
GUARD.protect(nether.world);
assertEq("a world without a clock is not given a time",
List.of("setDifficulty PEACEFUL", "setStorm false", "setThundering false"), nether.calls);
// Paper 26 throws for setTime on a clockless world; had the check missed one, the
// plugin, and with it /menu, would not come up.
FakeWorld odd = new FakeWorld("odd", false, true);
WARNINGS.clear();
GUARD.protect(odd.world);
assertEq("a rule the server refuses is logged", true,
WARNINGS.stream().anyMatch(r -> r.getMessage().contains("world odd")));
assertEq("... as a warning, not a crash", Level.WARNING, WARNINGS.get(0).getLevel());
}
// ---- helpers ----
private record Case(String what, Function<Player, Cancellable> event) {
boolean fire(Player p) {
return dispatch(event.apply(p));
}
}
private static PlayerInteractEvent interact(Player p, Action action, Material type) {
return new PlayerInteractEvent(p, action, null, Fakes.block(type), BlockFace.UP, EquipmentSlot.HAND);
}
private static EntityDamageByEntityEvent hit(Entity damager, Entity victim) {
return new EntityDamageByEntityEvent(damager, victim, DamageCause.ENTITY_ATTACK, HIT, 4);
}
private static Arrow arrowFrom(Player shooter) {
return Fakes.fake(Arrow.class, (m, a) -> m.equals("getShooter") ? shooter : Fakes.UNANSWERED);
}
/**
* dispatch hands the event to every LobbyGuard handler that takes its type, the way
* Bukkit would, and reports whether it ended up cancelled.
*/
private static boolean dispatch(Cancellable event) {
if (event instanceof PlayerArmorStandManipulateEvent e) {
GUARD.onArmorStand(e);
} else if (event instanceof PlayerInteractEntityEvent e) {
GUARD.onFrame(e);
} else if (event instanceof PlayerInteractEvent e) {
GUARD.onTrample(e);
} else if (event instanceof BlockBreakEvent e) {
GUARD.onBreak(e);
} else if (event instanceof BlockPlaceEvent e) {
GUARD.onPlace(e);
} else if (event instanceof PlayerBucketEmptyEvent e) {
GUARD.onPour(e);
} else if (event instanceof PlayerBucketFillEvent e) {
GUARD.onScoop(e);
} else if (event instanceof BlockIgniteEvent e) {
GUARD.onIgnite(e);
} else if (event instanceof BlockBurnEvent e) {
GUARD.onBurn(e);
} else if (event instanceof HangingBreakByEntityEvent e) {
GUARD.onHangingBreak(e);
} else if (event instanceof VehicleDestroyEvent e) {
GUARD.onVehicleDestroy(e);
} else if (event instanceof FoodLevelChangeEvent e) {
GUARD.onHunger(e);
} else if (event instanceof EntityDamageByEntityEvent e) {
// Bukkit hands a by-entity hit to the plain damage handlers too.
GUARD.onHit(e);
GUARD.onDamage(e);
} else if (event instanceof EntityDamageEvent e) {
GUARD.onDamage(e);
} else {
throw new AssertionError("no handler for " + event.getClass().getSimpleName());
}
return event.isCancelled();
}
/** commands lists the run-command clicks anywhere in a component. */
private static List<String> commands(Component c) {
List<String> out = new ArrayList<>();
ClickEvent<?> click = c.clickEvent();
if (click != null && click.action() == ClickEvent.Action.RUN_COMMAND
&& click.payload() instanceof ClickEvent.Payload.Text text) {
out.add(text.value());
}
for (Component child : c.children()) {
out.addAll(commands(child));
}
return out;
}
private static Logger capturingLogger() {
Logger log = Logger.getLogger("LobbyGuardTest");
log.setUseParentHandlers(false);
log.addHandler(new Handler() {
@Override
public void publish(LogRecord record) {
if (record.getLevel().intValue() >= Level.WARNING.intValue()) {
WARNINGS.add(record);
}
}
@Override
public void flush() {
}
@Override
public void close() {
}
});
return log;
}
private static void assertEq(String what, Object want, Object got) {
if (want == null ? got != null : !want.equals(got)) {
throw new AssertionError(what + ": got " + got + ", want " + want);
}
checks++;
}
}
+10
View File
@@ -47,6 +47,13 @@
# frame budget. They ride the module's verified dependency set, which is why
# they live in Gradle rather than in the javac mains above.
#
# 4. The lobby guard self-test (`./gradlew lobbyTest` in plugins/paper): LobbyGuard
# runs against real paper-api events around fake players and worlds, so a
# passer-by cannot change the lobby while a builder can, nobody is hurt or
# starved, a fall into the void lands at spawn, a join sets adventure mode and
# names /menu with a click, and a world rule the server refuses is logged
# without taking the menu down with it.
#
# No test framework: the mains are the same javac one-liners their javadocs document,
# so a local run and CI run the same bytes.
set -euo pipefail
@@ -189,6 +196,9 @@ done
echo "==> plugins/velocity: ./gradlew --no-daemon routingTest"
( cd plugins/velocity && ./gradlew --no-daemon routingTest )
echo "==> plugins/paper: ./gradlew --no-daemon lobbyTest"
( cd plugins/paper && ./gradlew --no-daemon lobbyTest )
limbo_version="$(sed -n 's/^LIMBO_VERSION=//p' deploy/game-stack.lock)"
[ -n "$limbo_version" ] || { echo "deploy/game-stack.lock sets no LIMBO_VERSION" >&2; exit 1; }
echo "==> plugins/limbo: ./gradlew --no-daemon -PlimboVersion=${limbo_version} build"