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

@@ -5,6 +5,7 @@ import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkClient;
import best.lolicon.felis.link.LinkCode;
import best.lolicon.felis.link.LinkConfig;
import best.lolicon.felis.link.ExperienceConfig;
import best.lolicon.felis.link.LinkConfigLoader;
import com.loohp.limbo.events.EventHandler;
@@ -92,6 +93,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
private volatile Readiness readiness;
private volatile LoginFlow flow;
private ExperienceConfig experience;
@Override
public void onEnable() {
@@ -146,6 +148,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
try {
File props = new File(getDataFolder(), "felis-link.properties");
cfg = LinkConfigLoader.load(props.toPath());
experience = ExperienceConfig.load(ExperienceConfig.PATH);
} catch (IOException e) {
// Missing/half config: like the other Felis plugins, load un-crippled —
// readiness stays up, the login flow just never turns on.
@@ -161,7 +164,9 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
}
String consoleUrl = "https://" + panelHost;
String lobby = GateConfig.lobby(System.getenv("FELIS_LOBBY_SERVER"));
long timeoutMillis = GateConfig.loginTimeoutSeconds(System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS")) * 1000L;
long timeoutMillis = GateConfig.loginTimeoutSeconds(System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS") != null
? System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS")
: Long.toString(experience.number("loginTimeoutSeconds", GateConfig.DEFAULT_TIMEOUT_SECONDS))) * 1000L;
this.flow = new LoginFlow(new LimboGate(), new FelisApiClient(cfg), new LinkClient(cfg),
consoleUrl, timeoutMillis, System::currentTimeMillis, LOG);
LOG.info("FelisLimbo: login flow ON — console=" + consoleUrl
@@ -187,7 +192,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
@Override
public LoginFlow.Seat player(UUID id) {
Player player = getServer().getPlayer(id);
return player != null && player.isValid() ? new LimboSeat(player) : null;
return player != null && player.isValid() ? new LimboSeat(player, experience) : null;
}
@Override
@@ -222,7 +227,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
}
/** LimboSeat is one Limbo player as the flow sees them. */
private record LimboSeat(Player player) implements LoginFlow.Seat {
private record LimboSeat(Player player, ExperienceConfig experience) implements LoginFlow.Seat {
@Override
public String name() {
return player.getName();
@@ -235,7 +240,9 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
@Override
public void showCode(LinkCode code, String url) {
player.openBook(LoginBook.book(code.code(), url));
if (experience.flag("openBook", true)) {
player.openBook(LoginBook.book(code.code(), url, experience));
}
}
@Override
@@ -1,5 +1,7 @@
package best.lolicon.felis.limbo;
import best.lolicon.felis.link.ExperienceConfig;
import java.util.Map;
import net.kyori.adventure.inventory.Book;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
@@ -30,15 +32,20 @@ final class LoginBook {
}
static Book book(String code, String url) {
Component page = Component.text("Felis 登录 / Login\n\n")
return book(code, url, new ExperienceConfig(Map.of()));
}
static Book book(String code, String url, ExperienceConfig settings) {
Component page = Component.text(settings.text("bookHeading", "Felis 登录 / Login") + "\n\n")
.append(Component.text("绑定码 / Code:\n"))
.append(Component.text(code + "\n\n").color(NamedTextColor.GOLD))
.append(Component.text("▶ 点此打开登录页\n▶ Open login page\n")
.append(Component.text(settings.text("bookAction", "▶ 点此打开登录页\n▶ Open login page") + "\n")
.color(NamedTextColor.AQUA)
.clickEvent(ClickEvent.openUrl(url)))
.append(Component.text("\n在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).")
.append(Component.text("\n" + settings.text("bookHelp", "在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there)."))
.color(NamedTextColor.GRAY));
return Book.book(Component.text("Felis Login"), Component.text("Felis"), page);
return Book.book(Component.text(settings.text("bookTitle", "Felis Login")),
Component.text(settings.text("bookAuthor", "Felis")), page);
}
/** chatLine renders one chat line with § colour codes, its web addresses clickable. */
@@ -1,5 +1,6 @@
package best.lolicon.felis.limbo;
import best.lolicon.felis.link.ExperienceConfig;
import best.lolicon.felis.link.Control;
import best.lolicon.felis.link.ControlFrame;
import best.lolicon.felis.link.FelisApiClient;
@@ -24,6 +25,8 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -356,7 +359,22 @@ public final class LoginFlowTest {
assertEq("lobby set", "hub", GateConfig.lobby(" hub "));
}
private static void loginBook() {
private static void loginBook() throws IOException {
Path file = Files.createTempFile("felis-experience", ".json");
ExperienceConfig settings;
try {
Files.writeString(file, "{\"bookTitle\":\"My Network\",\"bookHeading\":\"欢迎 / Welcome\"}");
settings = ExperienceConfig.load(file);
} finally {
Files.deleteIfExists(file);
}
Book customized = LoginBook.book(CODE, CONSOLE + "/link", settings);
assertEq("custom book title", "My Network", plain(customized.title()));
assertTrue("custom book reads UTF-8 from the world volume", plain(customized.pages().get(0)).contains("欢迎 / Welcome"));
assertEq("custom book keeps the code", true, plain(customized.pages().get(0)).contains(CODE));
List<Component> customLinks = new ArrayList<>();
collectLinks(customized.pages().get(0), customLinks);
assertEq("custom book keeps the generated login URL", CONSOLE + "/link", customLinks.get(0).clickEvent().value());
Book book = LoginBook.book(CODE, CONSOLE + "/link");
assertEq("book title", "Felis Login", plain(book.title()));
Component page = book.pages().get(0);
+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();
@@ -0,0 +1,43 @@
package best.lolicon.felis.link;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
/** Player-facing settings on the persistent world volume, edited by the panel. */
public final class ExperienceConfig {
public static final Path PATH = Path.of("felis-experience.json");
private final Map<?, ?> values;
public ExperienceConfig(Map<?, ?> values) {
this.values = values;
}
public static ExperienceConfig load(Path path) throws IOException {
if (!Files.exists(path)) {
return new ExperienceConfig(Map.of());
}
try {
Object value = Json.parse(Files.readString(path));
if (value instanceof Map<?, ?> map) {
return new ExperienceConfig(map);
}
throw new IllegalArgumentException("expected an object");
} catch (IllegalArgumentException e) {
throw new IOException("invalid " + path + ": " + e.getMessage(), e);
}
}
public String text(String key, String fallback) {
return values.get(key) instanceof String value ? value : fallback;
}
public boolean flag(String key, boolean fallback) {
return values.get(key) instanceof Boolean value ? value : fallback;
}
public long number(String key, long fallback) {
return values.get(key) instanceof Number value ? value.longValue() : fallback;
}
}