feat: customize login and lobby spaces from the panel

This commit is contained in:
Lemon-miaow committed 2026-10-04 12:38:39 +08:00
1 parent efbbe27629
commit 10bd2ddad0
54 files changed
+1146 -158

No files matched your search

+3 -1
View File
@@ -41,7 +41,8 @@ dependencies {
// plugin-message channel and never holds a felis-api token or talks to felis-api
// directly. We enforce that physically here — the shared source root is on the
// path, but the include filter ships ONLY the paper package and the three codec
// files (Control + ControlFrame + Json). FelisApiClient, LinkClient and the token
// files (Control + ControlFrame + Json) plus the local ExperienceConfig reader.
// FelisApiClient, LinkClient and the token
// config are not compiled in at all, so the lobby cannot reach the API even by
// mistake. If a codec class grows a new dependency, compilation fails loudly here
// rather than silently widening the lobby's reach.
@@ -53,6 +54,7 @@ sourceSets {
include 'best/lolicon/felis/link/Control.java'
include 'best/lolicon/felis/link/ControlFrame.java'
include 'best/lolicon/felis/link/Json.java'
include 'best/lolicon/felis/link/ExperienceConfig.java'
}
}
}
@@ -2,6 +2,9 @@ package best.lolicon.felis.paper;
import best.lolicon.felis.link.Control;
import best.lolicon.felis.link.ControlFrame;
import best.lolicon.felis.link.ExperienceConfig;
import java.io.IOException;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
@@ -62,10 +65,16 @@ import java.util.List;
public final class FelisPaperPlugin extends JavaPlugin {
private LobbyMenu menu;
private ExperienceConfig experience;
@Override
public void onEnable() {
saveDefaultConfig();
try {
experience = ExperienceConfig.load(ExperienceConfig.PATH);
} catch (IOException e) {
throw new IllegalStateException("Cannot load lobby settings", e);
}
if (!getConfig().getStringList("servers").isEmpty()) {
getLogger().info("config.yml 'servers' is no longer read: the menu lists what the proxy routes.");
}
@@ -81,7 +90,7 @@ public final class FelisPaperPlugin extends JavaPlugin {
// 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());
LobbyGuard guard = new LobbyGuard(getLogger(), experience);
getServer().getPluginManager().registerEvents(guard, this);
getServer().getWorlds().forEach(guard::protect);
@@ -125,6 +134,11 @@ public final class FelisPaperPlugin extends JavaPlugin {
return FelisPaperPlugin.zh(player);
}
@Override
public String menuTitle() {
return experience.text(zh() ? "menuTitleZh" : "menuTitleEn", zh() ? "Felis 服务器" : "Felis Servers");
}
@Override
public boolean online() {
return player.isOnline();
@@ -1,5 +1,6 @@
package best.lolicon.felis.paper;
import best.lolicon.felis.link.ExperienceConfig;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
@@ -36,6 +37,7 @@ import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.vehicle.VehicleDestroyEvent;
import org.bukkit.event.world.WorldLoadEvent;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -45,6 +47,8 @@ import java.util.logging.Logger;
* 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>The following are defaults; felis-experience.json can adjust each behavior.
*
* <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
@@ -70,14 +74,20 @@ final class LobbyGuard implements Listener {
/** Noon: the lobby is always lit. */
private static final long NOON = 6000L;
static boolean guarded(Player player) {
return !player.hasPermission(BUILD_PERMISSION);
boolean guarded(Player player) {
return settings.flag("protectBuild", true) && !player.hasPermission(BUILD_PERMISSION);
}
private final Logger log;
private final ExperienceConfig settings;
LobbyGuard(Logger log) {
this(log, new ExperienceConfig(Map.of()));
}
LobbyGuard(Logger log, ExperienceConfig settings) {
this.log = log;
this.settings = settings;
}
/**
@@ -94,25 +104,27 @@ final class LobbyGuard implements Listener {
}
}
static void applyRules(World world) {
world.setDifficulty(Difficulty.PEACEFUL);
void applyRules(World world) {
world.setDifficulty(Difficulty.valueOf(settings.text("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);
if (settings.flag("freezeTime", true) && !world.isFixedTime()) {
world.setTime(Math.floorMod(settings.number("time", NOON), 24000L));
}
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);
if (settings.flag("clearWeather", true)) {
world.setStorm(false);
world.setThundering(false);
}
world.setGameRule(GameRules.PVP, settings.flag("pvp", false));
world.setGameRule(GameRules.SPAWN_MOBS, settings.flag("spawnMobs", false));
world.setGameRule(GameRules.SPAWN_WANDERING_TRADERS, settings.flag("spawnMobs", false));
world.setGameRule(GameRules.MOB_GRIEFING, settings.flag("mobGriefing", false));
world.setGameRule(GameRules.TNT_EXPLODES, settings.flag("tntExplodes", false));
world.setGameRule(GameRules.KEEP_INVENTORY, settings.flag("keepInventory", true));
world.setGameRule(GameRules.IMMEDIATE_RESPAWN, settings.flag("immediateRespawn", true));
world.setGameRule(GameRules.SHOW_ADVANCEMENT_MESSAGES, settings.flag("showAdvancementMessages", false));
world.setGameRule(GameRules.ADVANCE_TIME, !settings.flag("freezeTime", true));
world.setGameRule(GameRules.ADVANCE_WEATHER, !settings.flag("clearWeather", true));
}
@EventHandler
@@ -125,20 +137,30 @@ final class LobbyGuard implements Listener {
@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());
if (settings.flag("disableHunger", true)) {
player.setFoodLevel(20);
}
if (!player.hasPermission(BUILD_PERMISSION)) {
player.setGameMode(GameMode.valueOf(settings.text("gameMode", "ADVENTURE")));
if (settings.flag("teleportOnJoin", true)) {
player.teleport(player.getWorld().getSpawnLocation());
}
}
if (settings.flag("showWelcome", true)) {
player.sendMessage(hint(FelisPaperPlugin.zh(player)));
}
if (settings.flag("openMenuOnJoin", false)) {
player.performCommand("menu");
}
player.sendMessage(hint(FelisPaperPlugin.zh(player)));
}
static Component hint(boolean zh) {
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 ",
return Component.text(settings.text(zh ? "welcomeZh" : "welcomeEn",
zh ? "欢迎来到大厅。输入 /menu 或点击 " : "Welcome to the lobby. Type /menu or click "),
NamedTextColor.GOLD)
.append(open)
.append(Component.text(zh ? ",选一个服务器进入。" : " to pick a server to join.",
@@ -152,15 +174,17 @@ final class LobbyGuard implements Listener {
if (!(event.getEntity() instanceof Player player)) {
return;
}
event.setCancelled(true);
if (event.getCause() == EntityDamageEvent.DamageCause.VOID) {
if (settings.flag("invulnerable", true)) {
event.setCancelled(true);
}
if (settings.flag("voidRescue", true) && event.getCause() == EntityDamageEvent.DamageCause.VOID) {
player.teleport(player.getWorld().getSpawnLocation());
}
}
@EventHandler(priority = EventPriority.LOW)
public void onHunger(FoodLevelChangeEvent event) {
if (event.getEntity() instanceof Player) {
if (settings.flag("disableHunger", true) && event.getEntity() instanceof Player) {
event.setCancelled(true);
}
}
@@ -211,14 +235,17 @@ final class LobbyGuard implements Listener {
/** 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)) {
if (settings.flag("protectBuild", true) &&
(!(event.getIgnitingEntity() instanceof Player player) || guarded(player))) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.LOW)
public void onBurn(BlockBurnEvent event) {
event.setCancelled(true);
if (settings.flag("protectBuild", true)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.LOW)
@@ -258,7 +285,7 @@ final class LobbyGuard implements Listener {
}
/** guardedCulprit: the entity is a guarded player, or something one of them shot. */
private static boolean guardedCulprit(Entity culprit) {
private boolean guardedCulprit(Entity culprit) {
if (culprit instanceof Projectile projectile && projectile.getShooter() instanceof Player shooter) {
return guarded(shooter);
}
@@ -42,6 +42,10 @@ final class LobbyMenu implements Listener, PluginMessageListener {
/** zh mirrors the Velocity rule: Chinese when the client locale is zh-*. */
boolean zh();
default String menuTitle() {
return zh() ? "Felis 服务器" : "Felis Servers";
}
boolean online();
void send(ControlFrame frame);
@@ -189,7 +193,7 @@ final class LobbyMenu implements Listener, PluginMessageListener {
slots[NEXT_SLOT] = new Nav(true, zh ? "下一页" : "Next page");
}
}
screen.open(holder, title(zh, p, pages), size, slots);
screen.open(holder, title(screen.menuTitle(), p, pages), size, slots);
// Ask the proxy for live status of every tile on this page; answers repaint them.
for (String server : view) {
screen.send(ControlFrame.statusQuery(server));
@@ -286,7 +290,10 @@ final class LobbyMenu implements Listener, PluginMessageListener {
// ---- text ----
static String title(boolean zh, int page, int pages) {
String title = zh ? "Felis 服务器" : "Felis Servers";
return title(zh ? "Felis 服务器" : "Felis Servers", page, pages);
}
static String title(String title, int page, int pages) {
if (pages > 1) {
title += " (" + (page + 1) + "/" + pages + ")";
}
+4 -1
View File
@@ -4,7 +4,10 @@
# channel to the Velocity proxy. It holds no felis-api token and never contacts
# felis-api directly.
#
# There is nothing to configure. /menu asks the proxy which servers to show
# Player-facing settings are edited in the panel at /admin/lobby and stored in
# /data/felis-experience.json. This file does not hold those settings.
#
# /menu asks the proxy which servers to show
# (a ListRequest over felis:control) and the proxy answers from the same registry
# it routes by, so a server created in the panel appears here on its own. Tiles are
# paged 45 at a time. Status, ownership and autostart stay with the proxy and
@@ -1,6 +1,8 @@
package best.lolicon.felis.paper;
import best.lolicon.felis.link.ExperienceConfig;
import best.lolicon.felis.paper.Fakes.FakePlayer;
import java.util.Map;
import best.lolicon.felis.paper.Fakes.FakeWorld;
import net.kyori.adventure.text.Component;
@@ -82,6 +84,7 @@ public final class LobbyGuardTest {
nobodyIsHurt();
joining();
worldRules();
customization();
System.out.println("LobbyGuardTest OK (" + checks + " checks)");
}
@@ -196,6 +199,33 @@ public final class LobbyGuardTest {
assertEq("... with the same click", List.of("/menu"), commands(zh.messages.get(0)));
}
private static void customization() {
LobbyGuard custom = new LobbyGuard(capturingLogger(), new ExperienceConfig(Map.of(
"protectBuild", false, "invulnerable", false, "disableHunger", false,
"teleportOnJoin", false, "gameMode", "SURVIVAL", "welcomeEn", "Hello, builders! ",
"difficulty", "HARD", "freezeTime", false, "clearWeather", false)));
FakePlayer player = new FakePlayer(false, world, Locale.US);
custom.onJoin(new PlayerJoinEvent(player.player, Component.text("joined")));
assertEq("custom mode", GameMode.SURVIVAL, player.gameMode);
assertEq("custom join does not teleport", 0, player.teleports.size());
assertEq("hunger remains enabled", 3, player.food);
assertEq("custom welcome keeps menu click", List.of("/menu"), commands(player.messages.get(0)));
assertEq("custom welcome", true, Fakes.text(player.messages.get(0)).startsWith("Hello, builders!"));
BlockBreakEvent build = new BlockBreakEvent(Fakes.block(Material.STONE), player.player);
custom.onBreak(build);
assertEq("building can be opened", false, build.isCancelled());
EntityDamageEvent damage = new EntityDamageEvent(player.player, DamageCause.FALL, HIT, 4);
custom.onDamage(damage);
assertEq("damage can be enabled", false, damage.isCancelled());
FoodLevelChangeEvent hunger = new FoodLevelChangeEvent(player.player, 2);
custom.onHunger(hunger);
assertEq("hunger can be enabled", false, hunger.isCancelled());
FakeWorld natural = new FakeWorld();
custom.protect(natural.world);
assertEq("custom difficulty, advancing time and weather", List.of("setDifficulty HARD"), natural.calls);
assertEq("custom menu keeps pagination", "My Hub (2/3)", LobbyMenu.title("My Hub", 1, 3));
}
private static void worldRules() {
FakeWorld overworld = new FakeWorld("world", false, false);
WARNINGS.clear();