fix(lobby): 大厅变成安全的中转大厅并提示 /menu,大厅上限 200、登录门不限人数
This commit is contained in:
12 files changed
+996
-4
No files matched your search
@@ -60,3 +60,25 @@ sourceSets {
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
// LobbyGuardTest drives the real LobbyGuard handlers with real paper-api events around
|
||||
// Proxy-built fakes (plain main, no framework, like the velocity routing tests). It is
|
||||
// not part of `build`, which the lobby image runs; plugins/test.sh runs `./gradlew lobbyTest`.
|
||||
sourceSets {
|
||||
lobbyTest {
|
||||
java {
|
||||
srcDir 'test'
|
||||
include 'best/lolicon/felis/paper/Fakes.java'
|
||||
include 'best/lolicon/felis/paper/LobbyGuardTest.java'
|
||||
}
|
||||
compileClasspath += sourceSets.main.output + configurations.compileClasspath
|
||||
runtimeClasspath += output + compileClasspath
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('lobbyTest', JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Runs the LobbyGuardTest self-test main.'
|
||||
classpath = sourceSets.lobbyTest.runtimeClasspath
|
||||
mainClass = 'best.lolicon.felis.paper.LobbyGuardTest'
|
||||
}
|
||||
@@ -85,6 +85,12 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
||||
getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, this);
|
||||
getServer().getPluginManager().registerEvents(this, this);
|
||||
|
||||
// The lobby is a hub nobody can hurt or be hurt in (LobbyGuard). Worlds loaded
|
||||
// before this point get the rules here, later ones on their WorldLoadEvent.
|
||||
LobbyGuard guard = new LobbyGuard(getLogger());
|
||||
getServer().getPluginManager().registerEvents(guard, this);
|
||||
getServer().getWorlds().forEach(guard::protect);
|
||||
|
||||
getLogger().info("felis-paper enabled: felis:control open, server list from the proxy. "
|
||||
+ "Pure UI face — no felis-api token.");
|
||||
}
|
||||
@@ -406,7 +412,7 @@ public final class FelisPaperPlugin extends JavaPlugin implements Listener, Plug
|
||||
// ---- helpers ----
|
||||
|
||||
/** zh mirrors the Velocity rule: render Chinese when the client locale is zh-*. */
|
||||
private static boolean zh(Player player) {
|
||||
static boolean zh(Player player) {
|
||||
return "zh".equalsIgnoreCase(player.locale().getLanguage());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package best.lolicon.felis.paper;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.event.HoverEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Difficulty;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.GameRules;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.ItemFrame;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.event.block.BlockBurnEvent;
|
||||
import org.bukkit.event.block.BlockIgniteEvent;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.FoodLevelChangeEvent;
|
||||
import org.bukkit.event.hanging.HangingBreakByEntityEvent;
|
||||
import org.bukkit.event.player.PlayerArmorStandManipulateEvent;
|
||||
import org.bukkit.event.player.PlayerBucketEmptyEvent;
|
||||
import org.bukkit.event.player.PlayerBucketFillEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.vehicle.VehicleDestroyEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* LobbyGuard keeps the lobby a hub: a place every authenticated player passes through
|
||||
* on the way to a server, which nobody can hurt, get hurt in, or leave a mark on.
|
||||
* Without it the lobby is a plain survival world: mobs at night, PvP, and every block
|
||||
* broken or placed by a passer-by stays in the world volume for the next player.
|
||||
*
|
||||
* <p><b>World.</b> Every world is made peaceful with natural spawning, PvP, mob
|
||||
* griefing and TNT off, time frozen at noon and the weather clear, and inventories
|
||||
* kept. These are world rules (level.dat), so they are set here on enable and on
|
||||
* every world load rather than in server.properties.
|
||||
*
|
||||
* <p><b>Players.</b> A player takes no damage and never goes hungry; one who falls out
|
||||
* of the world is put back at spawn. Without {@link #BUILD_PERMISSION} (ops have it by
|
||||
* default) a player joins in adventure mode at spawn and cannot break or place
|
||||
* blocks, pour or scoop liquids, trample farmland, light fires, or harm any entity:
|
||||
* mobs, item frames, paintings, armor stands and vehicles alike. Buttons, doors,
|
||||
* pressure plates and containers keep working, so a lobby an admin builds with
|
||||
* them still works for everyone.
|
||||
*
|
||||
* <p><b>Onboarding.</b> Every join gets one chat line saying what the lobby is for,
|
||||
* with a click that runs {@code /menu}: the only way on from here, and one nobody
|
||||
* would otherwise guess.
|
||||
*/
|
||||
final class LobbyGuard implements Listener {
|
||||
|
||||
/** Lets a player build in the lobby (default: ops). */
|
||||
static final String BUILD_PERMISSION = "felis.lobby.build";
|
||||
|
||||
/** Noon: the lobby is always lit. */
|
||||
private static final long NOON = 6000L;
|
||||
|
||||
static boolean guarded(Player player) {
|
||||
return !player.hasPermission(BUILD_PERMISSION);
|
||||
}
|
||||
|
||||
private final Logger log;
|
||||
|
||||
LobbyGuard(Logger log) {
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
/**
|
||||
* protect applies the world rules and logs what it could not set. The lobby's job is
|
||||
* the menu, so a rule a server version refuses (or no longer has) costs the rule,
|
||||
* never the plugin.
|
||||
*/
|
||||
void protect(World world) {
|
||||
try {
|
||||
applyRules(world);
|
||||
} catch (RuntimeException | LinkageError e) {
|
||||
log.log(Level.WARNING, "could not apply every lobby rule to world " + world.getName()
|
||||
+ "; the menu is unaffected", e);
|
||||
}
|
||||
}
|
||||
|
||||
static void applyRules(World world) {
|
||||
world.setDifficulty(Difficulty.PEACEFUL);
|
||||
// Only a world with its own clock has a time of day to set; the nether and the
|
||||
// end have none and refuse.
|
||||
if (!world.isFixedTime()) {
|
||||
world.setTime(NOON);
|
||||
}
|
||||
world.setStorm(false);
|
||||
world.setThundering(false);
|
||||
world.setGameRule(GameRules.PVP, false);
|
||||
world.setGameRule(GameRules.SPAWN_MOBS, false);
|
||||
world.setGameRule(GameRules.SPAWN_WANDERING_TRADERS, false);
|
||||
world.setGameRule(GameRules.MOB_GRIEFING, false);
|
||||
world.setGameRule(GameRules.TNT_EXPLODES, false);
|
||||
world.setGameRule(GameRules.KEEP_INVENTORY, true);
|
||||
world.setGameRule(GameRules.IMMEDIATE_RESPAWN, true);
|
||||
world.setGameRule(GameRules.SHOW_ADVANCEMENT_MESSAGES, false);
|
||||
world.setGameRule(GameRules.ADVANCE_TIME, false);
|
||||
world.setGameRule(GameRules.ADVANCE_WEATHER, false);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onWorldLoad(WorldLoadEvent event) {
|
||||
protect(event.getWorld());
|
||||
}
|
||||
|
||||
// ---- joining ----
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
player.setFoodLevel(20);
|
||||
if (guarded(player)) {
|
||||
player.setGameMode(GameMode.ADVENTURE);
|
||||
player.teleport(player.getWorld().getSpawnLocation());
|
||||
}
|
||||
player.sendMessage(hint(FelisPaperPlugin.zh(player)));
|
||||
}
|
||||
|
||||
static Component hint(boolean zh) {
|
||||
Component open = Component.text(zh ? "[打开服务器菜单]" : "[Open the server menu]",
|
||||
NamedTextColor.GREEN, TextDecoration.BOLD)
|
||||
.clickEvent(ClickEvent.runCommand("/menu"))
|
||||
.hoverEvent(HoverEvent.showText(Component.text(zh ? "点击运行 /menu" : "Click to run /menu")));
|
||||
return Component.text(zh ? "欢迎来到大厅。输入 /menu 或点击 " : "Welcome to the lobby. Type /menu or click ",
|
||||
NamedTextColor.GOLD)
|
||||
.append(open)
|
||||
.append(Component.text(zh ? ",选一个服务器进入。" : " to pick a server to join.",
|
||||
NamedTextColor.GOLD));
|
||||
}
|
||||
|
||||
// ---- nobody gets hurt ----
|
||||
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onDamage(EntityDamageEvent event) {
|
||||
if (!(event.getEntity() instanceof Player player)) {
|
||||
return;
|
||||
}
|
||||
event.setCancelled(true);
|
||||
if (event.getCause() == EntityDamageEvent.DamageCause.VOID) {
|
||||
player.teleport(player.getWorld().getSpawnLocation());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onHunger(FoodLevelChangeEvent event) {
|
||||
if (event.getEntity() instanceof Player) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- nobody leaves a mark ----
|
||||
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onBreak(BlockBreakEvent event) {
|
||||
if (guarded(event.getPlayer())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onPlace(BlockPlaceEvent event) {
|
||||
if (guarded(event.getPlayer())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onPour(PlayerBucketEmptyEvent event) {
|
||||
if (guarded(event.getPlayer())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onScoop(PlayerBucketFillEvent event) {
|
||||
if (guarded(event.getPlayer())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stepping on farmland or turtle eggs destroys them; pressure plates are left alone. */
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onTrample(PlayerInteractEvent event) {
|
||||
Block block = event.getClickedBlock();
|
||||
if (event.getAction() != Action.PHYSICAL || block == null || !guarded(event.getPlayer())) {
|
||||
return;
|
||||
}
|
||||
Material type = block.getType();
|
||||
if (type == Material.FARMLAND || type == Material.TURTLE_EGG) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire spreads and burns with nobody behind it, so only a builder may start one. */
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onIgnite(BlockIgniteEvent event) {
|
||||
if (!(event.getIgnitingEntity() instanceof Player player) || guarded(player)) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onBurn(BlockBurnEvent event) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onHit(EntityDamageByEntityEvent event) {
|
||||
if (guardedCulprit(event.getDamager())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onHangingBreak(HangingBreakByEntityEvent event) {
|
||||
if (guardedCulprit(event.getRemover())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onVehicleDestroy(VehicleDestroyEvent event) {
|
||||
if (guardedCulprit(event.getAttacker())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Right-clicking an item frame takes or turns what it holds. */
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onFrame(PlayerInteractEntityEvent event) {
|
||||
if (event.getRightClicked() instanceof ItemFrame && guarded(event.getPlayer())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOW)
|
||||
public void onArmorStand(PlayerArmorStandManipulateEvent event) {
|
||||
if (guarded(event.getPlayer())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** guardedCulprit: the entity is a guarded player, or something one of them shot. */
|
||||
private static boolean guardedCulprit(Entity culprit) {
|
||||
if (culprit instanceof Projectile projectile && projectile.getShooter() instanceof Player shooter) {
|
||||
return guarded(shooter);
|
||||
}
|
||||
return culprit instanceof Player player && guarded(player);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ version: 0.1.0
|
||||
main: best.lolicon.felis.paper.FelisPaperPlugin
|
||||
api-version: '1.21'
|
||||
authors: [Felis]
|
||||
description: Lobby UI face — /menu and /server open a chest GUI that drives the felis:control channel.
|
||||
description: Lobby UI face — /menu and /server open a chest GUI that drives the felis:control channel; the lobby world is kept safe and unchanged.
|
||||
commands:
|
||||
menu:
|
||||
description: Open the Felis server menu.
|
||||
@@ -11,3 +11,7 @@ commands:
|
||||
server:
|
||||
description: Open the Felis server menu (alias of /menu).
|
||||
usage: /server
|
||||
permissions:
|
||||
felis.lobby.build:
|
||||
description: Build in the lobby. Everyone else joins in adventure mode and cannot change the world.
|
||||
default: op
|
||||
@@ -0,0 +1,178 @@
|
||||
package best.lolicon.felis.paper;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.TextComponent;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Fakes are the paper-api interfaces LobbyGuardTest drives the real listener with,
|
||||
* built with {@link Proxy}. They answer what the guard reads (a permission, a
|
||||
* world, its spawn, a block's type, a shooter) and record what it does to a player.
|
||||
*/
|
||||
final class Fakes {
|
||||
private Fakes() {
|
||||
}
|
||||
|
||||
/** Returned by an {@link Answers} for a method it does not answer. */
|
||||
static final Object UNANSWERED = new Object();
|
||||
|
||||
interface Answers {
|
||||
Object answer(String method, Object[] args) throws Throwable;
|
||||
}
|
||||
|
||||
/**
|
||||
* fake builds an instance of an interface. Methods the answers leave unanswered
|
||||
* return a zero value; the guard reads nothing it is not told.
|
||||
*/
|
||||
static <T> T fake(Class<T> type, Answers answers) {
|
||||
InvocationHandler h = (proxy, m, args) -> {
|
||||
Object[] a = args == null ? new Object[0] : args;
|
||||
if (m.getDeclaringClass() == Object.class) {
|
||||
switch (m.getName()) {
|
||||
case "equals":
|
||||
return proxy == a[0];
|
||||
case "hashCode":
|
||||
return System.identityHashCode(proxy);
|
||||
default:
|
||||
return type.getSimpleName() + "@fake";
|
||||
}
|
||||
}
|
||||
Object r = answers.answer(m.getName(), a);
|
||||
return r != UNANSWERED ? r : zero(m.getReturnType());
|
||||
};
|
||||
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, h));
|
||||
}
|
||||
|
||||
/** bare is an entity of the given type that answers nothing. */
|
||||
static <T> T bare(Class<T> type) {
|
||||
return fake(type, (m, a) -> UNANSWERED);
|
||||
}
|
||||
|
||||
private static Object zero(Class<?> t) {
|
||||
if (t == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (t == int.class || t == short.class || t == byte.class) {
|
||||
return 0;
|
||||
}
|
||||
if (t == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
if (t == double.class || t == float.class) {
|
||||
return 0.0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A world whose spawn is a fixed location, and what was set on it. A clockless world
|
||||
* (the nether, the end) says so through isFixedTime; refusesTime makes setTime throw
|
||||
* the way Paper does for one.
|
||||
*/
|
||||
static final class FakeWorld {
|
||||
final World world;
|
||||
final Location spawn;
|
||||
final List<String> calls = new ArrayList<>();
|
||||
|
||||
FakeWorld() {
|
||||
this("world", false, false);
|
||||
}
|
||||
|
||||
FakeWorld(String name, boolean fixedTime, boolean refusesTime) {
|
||||
Location[] at = new Location[1];
|
||||
world = fake(World.class, (m, a) -> {
|
||||
switch (m) {
|
||||
case "getSpawnLocation":
|
||||
return at[0].clone();
|
||||
case "getName":
|
||||
return name;
|
||||
case "isFixedTime":
|
||||
return fixedTime;
|
||||
case "setTime":
|
||||
if (refusesTime) {
|
||||
throw new IllegalArgumentException("Cannot set time in world without world clock");
|
||||
}
|
||||
calls.add("time " + a[0]);
|
||||
return null;
|
||||
case "setDifficulty":
|
||||
case "setStorm":
|
||||
case "setThundering":
|
||||
calls.add(m + " " + a[0]);
|
||||
return null;
|
||||
default:
|
||||
return UNANSWERED;
|
||||
}
|
||||
});
|
||||
at[0] = new Location(world, 8.5, 70, -3.5);
|
||||
spawn = at[0];
|
||||
}
|
||||
}
|
||||
|
||||
/** A player who either holds the lobby build permission or not, and what was done to them. */
|
||||
static final class FakePlayer {
|
||||
final Player player;
|
||||
final List<Location> teleports = new ArrayList<>();
|
||||
final List<Component> messages = new ArrayList<>();
|
||||
GameMode gameMode = GameMode.SURVIVAL;
|
||||
int food = 3;
|
||||
|
||||
FakePlayer(boolean builder, FakeWorld world, Locale locale) {
|
||||
player = fake(Player.class, (m, a) -> {
|
||||
switch (m) {
|
||||
case "hasPermission":
|
||||
return builder && LobbyGuard.BUILD_PERMISSION.equals(a[0]);
|
||||
case "getWorld":
|
||||
return world.world;
|
||||
case "locale":
|
||||
return locale;
|
||||
case "teleport":
|
||||
teleports.add((Location) a[0]);
|
||||
return true;
|
||||
case "setGameMode":
|
||||
gameMode = (GameMode) a[0];
|
||||
return null;
|
||||
case "getGameMode":
|
||||
return gameMode;
|
||||
case "setFoodLevel":
|
||||
food = (Integer) a[0];
|
||||
return null;
|
||||
case "sendMessage":
|
||||
if (a.length > 0 && a[0] instanceof Component c) {
|
||||
messages.add(c);
|
||||
return null;
|
||||
}
|
||||
return UNANSWERED;
|
||||
default:
|
||||
return UNANSWERED;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static Block block(Material type) {
|
||||
return fake(Block.class, (m, a) -> m.equals("getType") ? type : UNANSWERED);
|
||||
}
|
||||
|
||||
/** text is the plain text of a component built with Component.text. */
|
||||
static String text(Component c) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (c instanceof TextComponent t) {
|
||||
sb.append(t.content());
|
||||
}
|
||||
for (Component child : c.children()) {
|
||||
sb.append(text(child));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package best.lolicon.felis.paper;
|
||||
|
||||
import best.lolicon.felis.paper.Fakes.FakePlayer;
|
||||
import best.lolicon.felis.paper.Fakes.FakeWorld;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.damage.DamageSource;
|
||||
import org.bukkit.entity.ArmorStand;
|
||||
import org.bukkit.entity.Arrow;
|
||||
import org.bukkit.entity.Cow;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.ItemFrame;
|
||||
import org.bukkit.entity.Minecart;
|
||||
import org.bukkit.entity.Painting;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Villager;
|
||||
import org.bukkit.event.Cancellable;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.event.block.BlockBurnEvent;
|
||||
import org.bukkit.event.block.BlockIgniteEvent;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
|
||||
import org.bukkit.event.entity.FoodLevelChangeEvent;
|
||||
import org.bukkit.event.hanging.HangingBreakByEntityEvent;
|
||||
import org.bukkit.event.player.PlayerArmorStandManipulateEvent;
|
||||
import org.bukkit.event.player.PlayerBucketEmptyEvent;
|
||||
import org.bukkit.event.player.PlayerBucketFillEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.vehicle.VehicleDestroyEvent;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Function;
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* LobbyGuardTest drives the real LobbyGuard handlers with real paper-api events built
|
||||
* around fake players, worlds and entities: what a passer-by may not do to the lobby,
|
||||
* that a builder may, that nobody is hurt or starved and a fall into the void lands at
|
||||
* spawn, and what a player is told and set to on joining. Framework free: a failed
|
||||
* assertion throws.
|
||||
*
|
||||
* <p>The game rules resolve through the server's registry, which a fake cannot stand in
|
||||
* for; they are checked on a real Paper server. Here, the registry failing to load is
|
||||
* what a rule the server refuses looks like, and the guard has to log it and carry on.
|
||||
*
|
||||
* <p>Run: {@code ./gradlew lobbyTest} in plugins/paper.
|
||||
*/
|
||||
// The paper-api event constructors are marked for removal (the server builds them);
|
||||
// a test raising the events itself has no other way in.
|
||||
@SuppressWarnings("removal")
|
||||
public final class LobbyGuardTest {
|
||||
|
||||
private static final List<LogRecord> WARNINGS = new ArrayList<>();
|
||||
private static final LobbyGuard GUARD = new LobbyGuard(capturingLogger());
|
||||
private static final DamageSource HIT = Fakes.bare(DamageSource.class);
|
||||
|
||||
private static int checks;
|
||||
private static FakeWorld world;
|
||||
|
||||
public static void main(String[] args) {
|
||||
world = new FakeWorld();
|
||||
passerByLeavesNoMark();
|
||||
builderMayBuild();
|
||||
mechanismsStillWork();
|
||||
fire();
|
||||
nobodyIsHurt();
|
||||
joining();
|
||||
worldRules();
|
||||
System.out.println("LobbyGuardTest OK (" + checks + " checks)");
|
||||
}
|
||||
|
||||
/** Every way a player could change the world, as the event a guarded or builder player raises. */
|
||||
private static List<Case> marks() {
|
||||
List<Case> cases = new ArrayList<>();
|
||||
cases.add(new Case("break a block", p -> new BlockBreakEvent(Fakes.block(Material.STONE), p)));
|
||||
cases.add(new Case("place a block", p -> new BlockPlaceEvent(Fakes.block(Material.DIRT), null,
|
||||
Fakes.block(Material.STONE), null, p, true, EquipmentSlot.HAND)));
|
||||
cases.add(new Case("pour a bucket", p -> new PlayerBucketEmptyEvent(p, Fakes.block(Material.AIR),
|
||||
Fakes.block(Material.STONE), BlockFace.UP, Material.LAVA_BUCKET, null, EquipmentSlot.HAND)));
|
||||
cases.add(new Case("scoop a bucket", p -> new PlayerBucketFillEvent(p, Fakes.block(Material.WATER),
|
||||
Fakes.block(Material.STONE), BlockFace.UP, Material.BUCKET, null, EquipmentSlot.HAND)));
|
||||
cases.add(new Case("trample farmland", p -> interact(p, Action.PHYSICAL, Material.FARMLAND)));
|
||||
cases.add(new Case("trample turtle eggs", p -> interact(p, Action.PHYSICAL, Material.TURTLE_EGG)));
|
||||
cases.add(new Case("hit a cow", p -> hit(p, Fakes.bare(Cow.class))));
|
||||
cases.add(new Case("hit a villager", p -> hit(p, Fakes.bare(Villager.class))));
|
||||
cases.add(new Case("shoot an armor stand", p -> hit(arrowFrom(p), Fakes.bare(ArmorStand.class))));
|
||||
cases.add(new Case("pop an item frame", p -> hit(p, Fakes.bare(ItemFrame.class))));
|
||||
cases.add(new Case("break a painting", p -> new HangingBreakByEntityEvent(Fakes.bare(Painting.class), p, HIT)));
|
||||
cases.add(new Case("shoot a painting down",
|
||||
p -> new HangingBreakByEntityEvent(Fakes.bare(Painting.class), arrowFrom(p), HIT)));
|
||||
cases.add(new Case("break a minecart", p -> new VehicleDestroyEvent(Fakes.bare(Minecart.class), HIT, p)));
|
||||
cases.add(new Case("turn an item frame", p -> new PlayerInteractEntityEvent(p, Fakes.bare(ItemFrame.class))));
|
||||
cases.add(new Case("strip an armor stand", p -> new PlayerArmorStandManipulateEvent(p,
|
||||
Fakes.bare(ArmorStand.class), null, null, EquipmentSlot.HEAD, EquipmentSlot.HAND)));
|
||||
cases.add(new Case("light a fire", p -> new BlockIgniteEvent(Fakes.block(Material.GRASS_BLOCK),
|
||||
BlockIgniteEvent.IgniteCause.FLINT_AND_STEEL, p)));
|
||||
return cases;
|
||||
}
|
||||
|
||||
private static void passerByLeavesNoMark() {
|
||||
Player p = new FakePlayer(false, world, Locale.US).player;
|
||||
for (Case c : marks()) {
|
||||
assertEq("a passer-by cannot " + c.what, true, c.fire(p));
|
||||
}
|
||||
}
|
||||
|
||||
private static void builderMayBuild() {
|
||||
Player p = new FakePlayer(true, world, Locale.US).player;
|
||||
for (Case c : marks()) {
|
||||
assertEq("a builder can " + c.what, false, c.fire(p));
|
||||
}
|
||||
}
|
||||
|
||||
private static void mechanismsStillWork() {
|
||||
Player p = new FakePlayer(false, world, Locale.US).player;
|
||||
assertEq("a pressure plate still works", false,
|
||||
dispatch(interact(p, Action.PHYSICAL, Material.STONE_PRESSURE_PLATE)));
|
||||
assertEq("a button still works", false,
|
||||
dispatch(interact(p, Action.RIGHT_CLICK_BLOCK, Material.STONE_BUTTON)));
|
||||
assertEq("a chest still opens", false, dispatch(interact(p, Action.RIGHT_CLICK_BLOCK, Material.CHEST)));
|
||||
assertEq("talking to a villager still works", false,
|
||||
dispatch(new PlayerInteractEntityEvent(p, Fakes.bare(Villager.class))));
|
||||
}
|
||||
|
||||
private static void fire() {
|
||||
assertEq("fire spreading on its own is stopped", true, dispatch(new BlockIgniteEvent(
|
||||
Fakes.block(Material.OAK_PLANKS), BlockIgniteEvent.IgniteCause.SPREAD, (Entity) null)));
|
||||
assertEq("lightning starts no fire", true, dispatch(new BlockIgniteEvent(
|
||||
Fakes.block(Material.GRASS_BLOCK), BlockIgniteEvent.IgniteCause.LIGHTNING, (Entity) null)));
|
||||
assertEq("fire burns nothing away", true, dispatch(new BlockBurnEvent(Fakes.block(Material.OAK_LOG), null)));
|
||||
}
|
||||
|
||||
private static void nobodyIsHurt() {
|
||||
FakePlayer builder = new FakePlayer(true, world, Locale.US);
|
||||
for (DamageCause cause : new DamageCause[]{DamageCause.FALL, DamageCause.LAVA, DamageCause.DROWNING,
|
||||
DamageCause.ENTITY_ATTACK, DamageCause.STARVATION}) {
|
||||
FakePlayer p = new FakePlayer(false, world, Locale.US);
|
||||
assertEq("a player takes no " + cause + " damage", true,
|
||||
dispatch(new EntityDamageEvent(p.player, cause, HIT, 4)));
|
||||
assertEq("... and stays where they are", 0, p.teleports.size());
|
||||
}
|
||||
assertEq("a builder takes no damage either", true,
|
||||
dispatch(new EntityDamageEvent(builder.player, DamageCause.FALL, HIT, 4)));
|
||||
FakePlayer other = new FakePlayer(false, world, Locale.US);
|
||||
assertEq("a player cannot hurt another", true,
|
||||
dispatch(new EntityDamageByEntityEvent(other.player, builder.player, DamageCause.ENTITY_ATTACK, HIT, 4)));
|
||||
|
||||
FakePlayer faller = new FakePlayer(true, world, Locale.US);
|
||||
assertEq("falling out of the world does no damage", true,
|
||||
dispatch(new EntityDamageEvent(faller.player, DamageCause.VOID, HIT, 4)));
|
||||
assertEq("... and lands the player at spawn", List.of(world.spawn), faller.teleports);
|
||||
|
||||
Cow cow = Fakes.bare(Cow.class);
|
||||
assertEq("a mob is not made immortal", false, dispatch(new EntityDamageEvent(cow, DamageCause.FALL, HIT, 4)));
|
||||
assertEq("nobody goes hungry", true,
|
||||
dispatch(new FoodLevelChangeEvent(new FakePlayer(false, world, Locale.US).player, 2)));
|
||||
}
|
||||
|
||||
private static void joining() {
|
||||
FakePlayer p = new FakePlayer(false, world, Locale.US);
|
||||
GUARD.onJoin(new PlayerJoinEvent(p.player, Component.text("joined")));
|
||||
assertEq("a passer-by joins in adventure mode", GameMode.ADVENTURE, p.gameMode);
|
||||
assertEq("... at spawn", List.of(world.spawn), p.teleports);
|
||||
assertEq("... fed", 20, p.food);
|
||||
assertEq("... and told about the menu once", 1, p.messages.size());
|
||||
Component hint = p.messages.get(0);
|
||||
assertEq("the hint names /menu", true, Fakes.text(hint).contains("Type /menu"));
|
||||
assertEq("the hint runs /menu when clicked", List.of("/menu"), commands(hint));
|
||||
|
||||
FakePlayer builder = new FakePlayer(true, world, Locale.US);
|
||||
builder.gameMode = GameMode.CREATIVE;
|
||||
GUARD.onJoin(new PlayerJoinEvent(builder.player, Component.text("joined")));
|
||||
assertEq("a builder keeps their game mode", GameMode.CREATIVE, builder.gameMode);
|
||||
assertEq("... and their place", 0, builder.teleports.size());
|
||||
assertEq("... and is told about the menu too", List.of("/menu"), commands(builder.messages.get(0)));
|
||||
|
||||
FakePlayer zh = new FakePlayer(false, world, Locale.SIMPLIFIED_CHINESE);
|
||||
GUARD.onJoin(new PlayerJoinEvent(zh.player, Component.text("joined")));
|
||||
assertEq("a Chinese client is told in Chinese", true, Fakes.text(zh.messages.get(0)).contains("输入 /menu"));
|
||||
assertEq("... with the same click", List.of("/menu"), commands(zh.messages.get(0)));
|
||||
}
|
||||
|
||||
private static void worldRules() {
|
||||
FakeWorld overworld = new FakeWorld("world", false, false);
|
||||
WARNINGS.clear();
|
||||
GUARD.onWorldLoad(new WorldLoadEvent(overworld.world));
|
||||
assertEq("a loaded world is made peaceful, noon and clear",
|
||||
List.of("setDifficulty PEACEFUL", "time 6000", "setStorm false", "setThundering false"),
|
||||
overworld.calls);
|
||||
|
||||
FakeWorld nether = new FakeWorld("world_nether", true, true);
|
||||
GUARD.protect(nether.world);
|
||||
assertEq("a world without a clock is not given a time",
|
||||
List.of("setDifficulty PEACEFUL", "setStorm false", "setThundering false"), nether.calls);
|
||||
|
||||
// Paper 26 throws for setTime on a clockless world; had the check missed one, the
|
||||
// plugin, and with it /menu, would not come up.
|
||||
FakeWorld odd = new FakeWorld("odd", false, true);
|
||||
WARNINGS.clear();
|
||||
GUARD.protect(odd.world);
|
||||
assertEq("a rule the server refuses is logged", true,
|
||||
WARNINGS.stream().anyMatch(r -> r.getMessage().contains("world odd")));
|
||||
assertEq("... as a warning, not a crash", Level.WARNING, WARNINGS.get(0).getLevel());
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private record Case(String what, Function<Player, Cancellable> event) {
|
||||
boolean fire(Player p) {
|
||||
return dispatch(event.apply(p));
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayerInteractEvent interact(Player p, Action action, Material type) {
|
||||
return new PlayerInteractEvent(p, action, null, Fakes.block(type), BlockFace.UP, EquipmentSlot.HAND);
|
||||
}
|
||||
|
||||
private static EntityDamageByEntityEvent hit(Entity damager, Entity victim) {
|
||||
return new EntityDamageByEntityEvent(damager, victim, DamageCause.ENTITY_ATTACK, HIT, 4);
|
||||
}
|
||||
|
||||
private static Arrow arrowFrom(Player shooter) {
|
||||
return Fakes.fake(Arrow.class, (m, a) -> m.equals("getShooter") ? shooter : Fakes.UNANSWERED);
|
||||
}
|
||||
|
||||
/**
|
||||
* dispatch hands the event to every LobbyGuard handler that takes its type, the way
|
||||
* Bukkit would, and reports whether it ended up cancelled.
|
||||
*/
|
||||
private static boolean dispatch(Cancellable event) {
|
||||
if (event instanceof PlayerArmorStandManipulateEvent e) {
|
||||
GUARD.onArmorStand(e);
|
||||
} else if (event instanceof PlayerInteractEntityEvent e) {
|
||||
GUARD.onFrame(e);
|
||||
} else if (event instanceof PlayerInteractEvent e) {
|
||||
GUARD.onTrample(e);
|
||||
} else if (event instanceof BlockBreakEvent e) {
|
||||
GUARD.onBreak(e);
|
||||
} else if (event instanceof BlockPlaceEvent e) {
|
||||
GUARD.onPlace(e);
|
||||
} else if (event instanceof PlayerBucketEmptyEvent e) {
|
||||
GUARD.onPour(e);
|
||||
} else if (event instanceof PlayerBucketFillEvent e) {
|
||||
GUARD.onScoop(e);
|
||||
} else if (event instanceof BlockIgniteEvent e) {
|
||||
GUARD.onIgnite(e);
|
||||
} else if (event instanceof BlockBurnEvent e) {
|
||||
GUARD.onBurn(e);
|
||||
} else if (event instanceof HangingBreakByEntityEvent e) {
|
||||
GUARD.onHangingBreak(e);
|
||||
} else if (event instanceof VehicleDestroyEvent e) {
|
||||
GUARD.onVehicleDestroy(e);
|
||||
} else if (event instanceof FoodLevelChangeEvent e) {
|
||||
GUARD.onHunger(e);
|
||||
} else if (event instanceof EntityDamageByEntityEvent e) {
|
||||
// Bukkit hands a by-entity hit to the plain damage handlers too.
|
||||
GUARD.onHit(e);
|
||||
GUARD.onDamage(e);
|
||||
} else if (event instanceof EntityDamageEvent e) {
|
||||
GUARD.onDamage(e);
|
||||
} else {
|
||||
throw new AssertionError("no handler for " + event.getClass().getSimpleName());
|
||||
}
|
||||
return event.isCancelled();
|
||||
}
|
||||
|
||||
/** commands lists the run-command clicks anywhere in a component. */
|
||||
private static List<String> commands(Component c) {
|
||||
List<String> out = new ArrayList<>();
|
||||
ClickEvent<?> click = c.clickEvent();
|
||||
if (click != null && click.action() == ClickEvent.Action.RUN_COMMAND
|
||||
&& click.payload() instanceof ClickEvent.Payload.Text text) {
|
||||
out.add(text.value());
|
||||
}
|
||||
for (Component child : c.children()) {
|
||||
out.addAll(commands(child));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Logger capturingLogger() {
|
||||
Logger log = Logger.getLogger("LobbyGuardTest");
|
||||
log.setUseParentHandlers(false);
|
||||
log.addHandler(new Handler() {
|
||||
@Override
|
||||
public void publish(LogRecord record) {
|
||||
if (record.getLevel().intValue() >= Level.WARNING.intValue()) {
|
||||
WARNINGS.add(record);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
});
|
||||
return log;
|
||||
}
|
||||
|
||||
private static void assertEq(String what, Object want, Object got) {
|
||||
if (want == null ? got != null : !want.equals(got)) {
|
||||
throw new AssertionError(what + ": got " + got + ", want " + want);
|
||||
}
|
||||
checks++;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user