feat: customize login and lobby spaces from the panel
This commit is contained in:
54 files changed
+1146
-158
No files matched your search
@@ -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,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();
|
||||
|
||||
Reference in new issue
Block a user