From 4425060d3ef8d3a21715401ddbdbbdd5924247a2 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sat, 26 Sep 2026 23:11:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(lobby):=20=E5=A4=A7=E5=8E=85=E5=8F=98?= =?UTF-8?q?=E6=88=90=E5=AE=89=E5=85=A8=E7=9A=84=E4=B8=AD=E8=BD=AC=E5=A4=A7?= =?UTF-8?q?=E5=8E=85=E5=B9=B6=E6=8F=90=E7=A4=BA=20/menu=EF=BC=8C=E5=A4=A7?= =?UTF-8?q?=E5=8E=85=E4=B8=8A=E9=99=90=20200=E3=80=81=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=97=A8=E4=B8=8D=E9=99=90=E4=BA=BA=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/limbo/README.md | 4 + deploy/limbo/entrypoint.sh | 11 +- deploy/lobby/README.md | 22 ++ deploy/lobby/entrypoint.sh | 14 +- game_entrypoint_test.go | 133 +++++++ plugins/paper/build.gradle | 22 ++ .../lolicon/felis/paper/FelisPaperPlugin.java | 8 +- .../best/lolicon/felis/paper/LobbyGuard.java | 267 ++++++++++++++ plugins/paper/src/main/resources/plugin.yml | 6 +- .../test/best/lolicon/felis/paper/Fakes.java | 178 ++++++++++ .../lolicon/felis/paper/LobbyGuardTest.java | 325 ++++++++++++++++++ plugins/test.sh | 10 + 12 files changed, 996 insertions(+), 4 deletions(-) create mode 100644 game_entrypoint_test.go create mode 100644 plugins/paper/src/main/java/best/lolicon/felis/paper/LobbyGuard.java create mode 100644 plugins/paper/test/best/lolicon/felis/paper/Fakes.java create mode 100644 plugins/paper/test/best/lolicon/felis/paper/LobbyGuardTest.java diff --git a/deploy/limbo/README.md b/deploy/limbo/README.md index 2b549bf..18c05ea 100644 --- a/deploy/limbo/README.md +++ b/deploy/limbo/README.md @@ -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): diff --git a/deploy/limbo/entrypoint.sh b/deploy/limbo/entrypoint.sh index 4af42d4..fbf58f8 100644 --- a/deploy/limbo/entrypoint.sh +++ b/deploy/limbo/entrypoint.sh @@ -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="" diff --git a/deploy/lobby/README.md b/deploy/lobby/README.md index 976255f..7d16741 100644 --- a/deploy/lobby/README.md +++ b/deploy/lobby/README.md @@ -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 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. diff --git a/deploy/lobby/entrypoint.sh b/deploy/lobby/entrypoint.sh index d733415..295adf3 100644 --- a/deploy/lobby/entrypoint.sh +++ b/deploy/lobby/entrypoint.sh @@ -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 diff --git a/game_entrypoint_test.go b/game_entrypoint_test.go new file mode 100644 index 0000000..1937da4 --- /dev/null +++ b/game_entrypoint_test.go @@ -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") + }) +} diff --git a/plugins/paper/build.gradle b/plugins/paper/build.gradle index 2fa8cd6..d5a2e5e 100644 --- a/plugins/paper/build.gradle +++ b/plugins/paper/build.gradle @@ -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' +} diff --git a/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java b/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java index b687f61..9aaded2 100644 --- a/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java +++ b/plugins/paper/src/main/java/best/lolicon/felis/paper/FelisPaperPlugin.java @@ -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()); } diff --git a/plugins/paper/src/main/java/best/lolicon/felis/paper/LobbyGuard.java b/plugins/paper/src/main/java/best/lolicon/felis/paper/LobbyGuard.java new file mode 100644 index 0000000..b82dd09 --- /dev/null +++ b/plugins/paper/src/main/java/best/lolicon/felis/paper/LobbyGuard.java @@ -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. + * + *

World. 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. + * + *

Players. 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. + * + *

Onboarding. 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); + } +} diff --git a/plugins/paper/src/main/resources/plugin.yml b/plugins/paper/src/main/resources/plugin.yml index ca0fd9f..83dedbb 100644 --- a/plugins/paper/src/main/resources/plugin.yml +++ b/plugins/paper/src/main/resources/plugin.yml @@ -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 diff --git a/plugins/paper/test/best/lolicon/felis/paper/Fakes.java b/plugins/paper/test/best/lolicon/felis/paper/Fakes.java new file mode 100644 index 0000000..596a35a --- /dev/null +++ b/plugins/paper/test/best/lolicon/felis/paper/Fakes.java @@ -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 fake(Class 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 bare(Class 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 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 teleports = new ArrayList<>(); + final List 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(); + } +} diff --git a/plugins/paper/test/best/lolicon/felis/paper/LobbyGuardTest.java b/plugins/paper/test/best/lolicon/felis/paper/LobbyGuardTest.java new file mode 100644 index 0000000..ea735d1 --- /dev/null +++ b/plugins/paper/test/best/lolicon/felis/paper/LobbyGuardTest.java @@ -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. + * + *

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. + * + *

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 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 marks() { + List 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 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 commands(Component c) { + List 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++; + } +} diff --git a/plugins/test.sh b/plugins/test.sh index 8ab14ce..75f1ceb 100644 --- a/plugins/test.sh +++ b/plugins/test.sh @@ -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"