fix(lobby): 大厅变成安全的中转大厅并提示 /menu,大厅上限 200、登录门不限人数
This commit is contained in:
12 files changed
+996
-4
No files matched your search
@@ -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`
|
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.
|
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)
|
## Configure (deployer's responsibility)
|
||||||
|
|
||||||
One setting this image does **not** guess (it keeps the release's own default):
|
One setting this image does **not** guess (it keeps the release's own default):
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ set_prop() {
|
|||||||
# The secret is base64/hex-ish, but a '/' or '&' would still break a bare sed s///.
|
# 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.
|
# '|' as the delimiter plus escaping it is enough for every value we write.
|
||||||
esc=$(printf '%s' "$2" | sed 's/[|\\&]/\\&/g')
|
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
|
else
|
||||||
printf '%s=%s\n' "$1" "$2" >> "$PROPS"
|
printf '%s=%s\n' "$1" "$2" >> "$PROPS"
|
||||||
fi
|
fi
|
||||||
@@ -75,6 +79,11 @@ set_prop bungeecord false
|
|||||||
set_prop bungee-guard false
|
set_prop bungee-guard false
|
||||||
set_prop velocity-modern true
|
set_prop velocity-modern true
|
||||||
set_prop forwarding-secrets "$SECRET"
|
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)"
|
echo "felis-limbo: server-port=${PORT}, velocity-modern=true (forwarding secret loaded, UUIDs are Mojang-verified)"
|
||||||
JAVA_MEMORY_ARG=""
|
JAVA_MEMORY_ARG=""
|
||||||
|
|||||||
@@ -46,3 +46,25 @@ sudo felis setup
|
|||||||
- Align `online-mode` / player forwarding with the off-cluster Velocity proxy.
|
- Align `online-mode` / player forwarding with the off-cluster Velocity proxy.
|
||||||
- The lobby speaks only the `felis:control` plugin-message channel; it holds no
|
- The lobby speaks only the `felis:control` plugin-message channel; it holds no
|
||||||
felis-api token by design (spec §12).
|
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.
|
||||||
@@ -57,7 +57,11 @@ set_prop() {
|
|||||||
# otherwise corrupt this bare sed s||| and silently break the key. Same escaping as
|
# 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.
|
# deploy/limbo — without it an unlucky password kills the console/permission channel.
|
||||||
esc=$(printf '%s' "$2" | sed 's/[|\\&]/\\&/g')
|
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
|
else
|
||||||
printf '%s=%s\n' "$1" "$2" >> "$PROPS"
|
printf '%s=%s\n' "$1" "$2" >> "$PROPS"
|
||||||
fi
|
fi
|
||||||
@@ -66,6 +70,14 @@ set_prop() {
|
|||||||
set_prop server-port "$PORT"
|
set_prop server-port "$PORT"
|
||||||
set_prop online-mode false
|
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
|
# 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
|
# 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
|
# it. Paper only reads these three keys from server.properties, so the operator's injected
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -60,3 +60,25 @@ sourceSets {
|
|||||||
tasks.withType(JavaCompile).configureEach {
|
tasks.withType(JavaCompile).configureEach {
|
||||||
options.encoding = 'UTF-8'
|
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().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, this);
|
||||||
getServer().getPluginManager().registerEvents(this, 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. "
|
getLogger().info("felis-paper enabled: felis:control open, server list from the proxy. "
|
||||||
+ "Pure UI face — no felis-api token.");
|
+ "Pure UI face — no felis-api token.");
|
||||||
}
|
}
|
||||||
@@ -406,7 +412,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
|||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|
||||||
/** zh mirrors the Velocity rule: render Chinese when the client locale is zh-*. */
|
/** 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());
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ version: 0.1.0
|
|||||||
main: best.lolicon.felis.paper.FelisPaperPlugin
|
main: best.lolicon.felis.paper.FelisPaperPlugin
|
||||||
api-version: '1.21'
|
api-version: '1.21'
|
||||||
authors: [Felis]
|
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:
|
commands:
|
||||||
menu:
|
menu:
|
||||||
description: Open the Felis server menu.
|
description: Open the Felis server menu.
|
||||||
@@ -11,3 +11,7 @@ commands:
|
|||||||
server:
|
server:
|
||||||
description: Open the Felis server menu (alias of /menu).
|
description: Open the Felis server menu (alias of /menu).
|
||||||
usage: /server
|
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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,13 @@
|
|||||||
# frame budget. They ride the module's verified dependency set, which is why
|
# frame budget. They ride the module's verified dependency set, which is why
|
||||||
# they live in Gradle rather than in the javac mains above.
|
# 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,
|
# No test framework: the mains are the same javac one-liners their javadocs document,
|
||||||
# so a local run and CI run the same bytes.
|
# so a local run and CI run the same bytes.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -189,6 +196,9 @@ done
|
|||||||
echo "==> plugins/velocity: ./gradlew --no-daemon routingTest"
|
echo "==> plugins/velocity: ./gradlew --no-daemon routingTest"
|
||||||
( cd 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)"
|
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; }
|
[ -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"
|
echo "==> plugins/limbo: ./gradlew --no-daemon -PlimboVersion=${limbo_version} build"
|
||||||
|
|||||||
Reference in new issue
Block a user