filtering + server lock
This commit is contained in:
@@ -4,6 +4,7 @@ import com.hypixel.hytale.logger.HytaleLogger;
|
|||||||
import com.hypixel.hytale.server.core.event.events.ShutdownEvent;
|
import com.hypixel.hytale.server.core.event.events.ShutdownEvent;
|
||||||
import com.hypixel.hytale.server.core.event.events.player.PlayerConnectEvent;
|
import com.hypixel.hytale.server.core.event.events.player.PlayerConnectEvent;
|
||||||
import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent;
|
import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent;
|
||||||
|
import com.hypixel.hytale.server.core.event.events.player.PlayerSetupConnectEvent;
|
||||||
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
|
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
|
||||||
import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
|
import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
|
||||||
import net.kewwbec.networkcore.api.DatabaseService;
|
import net.kewwbec.networkcore.api.DatabaseService;
|
||||||
@@ -18,6 +19,8 @@ import net.kewwbec.networkcore.command.NetworkCoreCommand;
|
|||||||
import net.kewwbec.networkcore.command.network.AlertCommand;
|
import net.kewwbec.networkcore.command.network.AlertCommand;
|
||||||
import net.kewwbec.networkcore.command.network.GlistCommand;
|
import net.kewwbec.networkcore.command.network.GlistCommand;
|
||||||
import net.kewwbec.networkcore.command.network.HubCommand;
|
import net.kewwbec.networkcore.command.network.HubCommand;
|
||||||
|
import net.kewwbec.networkcore.command.network.LockServerCommand;
|
||||||
|
import net.kewwbec.networkcore.command.network.SendAllCommand;
|
||||||
import net.kewwbec.networkcore.command.network.SendCommand;
|
import net.kewwbec.networkcore.command.network.SendCommand;
|
||||||
import net.kewwbec.networkcore.config.ConfigLoader;
|
import net.kewwbec.networkcore.config.ConfigLoader;
|
||||||
import net.kewwbec.networkcore.config.CoreConfig;
|
import net.kewwbec.networkcore.config.CoreConfig;
|
||||||
@@ -30,6 +33,8 @@ import net.kewwbec.networkcore.metrics.BuiltInMetrics;
|
|||||||
import net.kewwbec.networkcore.metrics.MetricsServiceImpl;
|
import net.kewwbec.networkcore.metrics.MetricsServiceImpl;
|
||||||
import net.kewwbec.networkcore.metrics.PrometheusExporter;
|
import net.kewwbec.networkcore.metrics.PrometheusExporter;
|
||||||
import net.kewwbec.networkcore.network.AlertService;
|
import net.kewwbec.networkcore.network.AlertService;
|
||||||
|
import net.kewwbec.networkcore.network.LockEnforcer;
|
||||||
|
import net.kewwbec.networkcore.network.LockService;
|
||||||
import net.kewwbec.networkcore.network.RefRequestService;
|
import net.kewwbec.networkcore.network.RefRequestService;
|
||||||
import net.kewwbec.networkcore.network.ShutdownHandoff;
|
import net.kewwbec.networkcore.network.ShutdownHandoff;
|
||||||
import net.kewwbec.networkcore.presence.PlayerPresenceService;
|
import net.kewwbec.networkcore.presence.PlayerPresenceService;
|
||||||
@@ -61,6 +66,7 @@ public final class NetworkCore extends JavaPlugin {
|
|||||||
@Nullable private PlayerPresenceService presenceService;
|
@Nullable private PlayerPresenceService presenceService;
|
||||||
@Nullable private AlertService alertService;
|
@Nullable private AlertService alertService;
|
||||||
@Nullable private RefRequestService refRequestService;
|
@Nullable private RefRequestService refRequestService;
|
||||||
|
@Nullable private LockService lockService;
|
||||||
@Nullable private ShutdownHandoff shutdownHandoff;
|
@Nullable private ShutdownHandoff shutdownHandoff;
|
||||||
|
|
||||||
public NetworkCore(@Nonnull JavaPluginInit init) {
|
public NetworkCore(@Nonnull JavaPluginInit init) {
|
||||||
@@ -157,6 +163,11 @@ public final class NetworkCore extends JavaPlugin {
|
|||||||
LobbyPicker lobbyPicker = new LobbyPicker(serverRegistry, config.server);
|
LobbyPicker lobbyPicker = new LobbyPicker(serverRegistry, config.server);
|
||||||
this.shutdownHandoff = new ShutdownHandoff(lobbyPicker, serverId);
|
this.shutdownHandoff = new ShutdownHandoff(lobbyPicker, serverId);
|
||||||
|
|
||||||
|
this.lockService = new LockService(serverId, serverRegistry, lobbyPicker, messageBus, getDataDirectory());
|
||||||
|
lockService.start();
|
||||||
|
LockEnforcer lockEnforcer = new LockEnforcer(lockService);
|
||||||
|
getEventRegistry().registerGlobal(PlayerSetupConnectEvent.class, lockEnforcer::onSetupConnect);
|
||||||
|
|
||||||
getEventRegistry().registerGlobal(
|
getEventRegistry().registerGlobal(
|
||||||
(short) (ShutdownEvent.DISCONNECT_PLAYERS - 1),
|
(short) (ShutdownEvent.DISCONNECT_PLAYERS - 1),
|
||||||
ShutdownEvent.class,
|
ShutdownEvent.class,
|
||||||
@@ -170,6 +181,8 @@ public final class NetworkCore extends JavaPlugin {
|
|||||||
getCommandRegistry().registerCommand(new GlistCommand(serverRegistry, presenceService));
|
getCommandRegistry().registerCommand(new GlistCommand(serverRegistry, presenceService));
|
||||||
getCommandRegistry().registerCommand(new HubCommand(lobbyPicker, refRequestService, serverId));
|
getCommandRegistry().registerCommand(new HubCommand(lobbyPicker, refRequestService, serverId));
|
||||||
getCommandRegistry().registerCommand(new SendCommand(serverRegistry, presenceService, refRequestService));
|
getCommandRegistry().registerCommand(new SendCommand(serverRegistry, presenceService, refRequestService));
|
||||||
|
getCommandRegistry().registerCommand(new SendAllCommand(serverRegistry, presenceService, refRequestService, serverId));
|
||||||
|
getCommandRegistry().registerCommand(new LockServerCommand(lockService, serverRegistry, serverId));
|
||||||
|
|
||||||
LOGGER.at(Level.INFO).log("NetworkCore started");
|
LOGGER.at(Level.INFO).log("NetworkCore started");
|
||||||
}
|
}
|
||||||
@@ -177,6 +190,10 @@ public final class NetworkCore extends JavaPlugin {
|
|||||||
@Override
|
@Override
|
||||||
protected void shutdown() {
|
protected void shutdown() {
|
||||||
shutdownHandoff = null;
|
shutdownHandoff = null;
|
||||||
|
if (lockService != null) {
|
||||||
|
try { lockService.stop(); } catch (RuntimeException ignored) {}
|
||||||
|
lockService = null;
|
||||||
|
}
|
||||||
if (refRequestService != null) {
|
if (refRequestService != null) {
|
||||||
try { refRequestService.stop(); } catch (RuntimeException ignored) {}
|
try { refRequestService.stop(); } catch (RuntimeException ignored) {}
|
||||||
refRequestService = null;
|
refRequestService = null;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ public final class NetworkCorePermissions {
|
|||||||
public static final String GLIST = ROOT + ".glist";
|
public static final String GLIST = ROOT + ".glist";
|
||||||
public static final String HUB = ROOT + ".hub";
|
public static final String HUB = ROOT + ".hub";
|
||||||
public static final String SEND = ROOT + ".send";
|
public static final String SEND = ROOT + ".send";
|
||||||
|
public static final String SENDALL = ROOT + ".sendall";
|
||||||
|
public static final String LOCKSERVER = ROOT + ".lockserver";
|
||||||
|
public static final String LOCKSERVER_BYPASS = ROOT + ".lockserver.bypass";
|
||||||
|
|
||||||
private NetworkCorePermissions() {
|
private NetworkCorePermissions() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.hypixel.hytale.component.Ref;
|
|||||||
import com.hypixel.hytale.component.Store;
|
import com.hypixel.hytale.component.Store;
|
||||||
import com.hypixel.hytale.server.core.Message;
|
import com.hypixel.hytale.server.core.Message;
|
||||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
import com.hypixel.hytale.server.core.universe.world.World;
|
import com.hypixel.hytale.server.core.universe.world.World;
|
||||||
@@ -19,16 +21,25 @@ import java.awt.Color;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /glist [filter]
|
||||||
|
*
|
||||||
|
* No filter: every server is listed.
|
||||||
|
* Filter matches with this priority: exact server id, then role name.
|
||||||
|
*/
|
||||||
public final class GlistCommand extends AbstractPlayerCommand {
|
public final class GlistCommand extends AbstractPlayerCommand {
|
||||||
|
|
||||||
private final ServerRegistry registry;
|
private final ServerRegistry registry;
|
||||||
private final PlayerPresence presence;
|
private final PlayerPresence presence;
|
||||||
|
private final OptionalArg<String> filterArg = withOptionalArg("filter", "Server id or role to filter by", ArgTypes.STRING);
|
||||||
|
|
||||||
public GlistCommand(@Nonnull ServerRegistry registry, @Nonnull PlayerPresence presence) {
|
public GlistCommand(@Nonnull ServerRegistry registry, @Nonnull PlayerPresence presence) {
|
||||||
super("glist", "List all online players across all servers");
|
super("glist", "List online players, optionally filtered by server id or role");
|
||||||
this.registry = registry;
|
this.registry = registry;
|
||||||
this.presence = presence;
|
this.presence = presence;
|
||||||
requirePermission(NetworkCorePermissions.GLIST);
|
requirePermission(NetworkCorePermissions.GLIST);
|
||||||
@@ -38,16 +49,32 @@ public final class GlistCommand extends AbstractPlayerCommand {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender, @Nonnull World world) {
|
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender, @Nonnull World world) {
|
||||||
Collection<ServerInfo> servers = registry.getServers();
|
String filter = filterArg.get(ctx);
|
||||||
ctx.sendMessage(Message.raw("=== Network ===").color(Color.YELLOW));
|
Collection<ServerInfo> servers = resolveServers(filter);
|
||||||
ctx.sendMessage(Message.raw("Servers: " + servers.size() + " | Online: " + presence.onlineCount()).color(Color.WHITE));
|
|
||||||
|
if (filter != null && !filter.isBlank() && servers.isEmpty()) {
|
||||||
|
ctx.sendMessage(Message.raw("No server matches id or role '" + filter + "'.").color(Color.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> includedIds = new LinkedHashSet<>();
|
||||||
|
for (ServerInfo s : servers) includedIds.add(s.id());
|
||||||
|
|
||||||
Map<String, List<PlayerLocation>> grouped = new LinkedHashMap<>();
|
Map<String, List<PlayerLocation>> grouped = new LinkedHashMap<>();
|
||||||
for (ServerInfo s : servers) grouped.put(s.id(), new ArrayList<>());
|
for (ServerInfo s : servers) grouped.put(s.id(), new ArrayList<>());
|
||||||
|
int filteredOnline = 0;
|
||||||
for (PlayerLocation loc : presence.all()) {
|
for (PlayerLocation loc : presence.all()) {
|
||||||
|
if (!includedIds.contains(loc.serverId())) continue;
|
||||||
grouped.computeIfAbsent(loc.serverId(), k -> new ArrayList<>()).add(loc);
|
grouped.computeIfAbsent(loc.serverId(), k -> new ArrayList<>()).add(loc);
|
||||||
|
filteredOnline++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String header = (filter == null || filter.isBlank())
|
||||||
|
? "=== Network ==="
|
||||||
|
: "=== Network (filter: " + filter + ") ===";
|
||||||
|
ctx.sendMessage(Message.raw(header).color(Color.YELLOW));
|
||||||
|
ctx.sendMessage(Message.raw("Servers: " + servers.size() + " | Online: " + filteredOnline).color(Color.WHITE));
|
||||||
|
|
||||||
for (Map.Entry<String, List<PlayerLocation>> e : grouped.entrySet()) {
|
for (Map.Entry<String, List<PlayerLocation>> e : grouped.entrySet()) {
|
||||||
ServerInfo s = registry.getServer(e.getKey());
|
ServerInfo s = registry.getServer(e.getKey());
|
||||||
String role = (s == null) ? "?" : s.role();
|
String role = (s == null) ? "?" : s.role();
|
||||||
@@ -66,4 +93,12 @@ public final class GlistCommand extends AbstractPlayerCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private Collection<ServerInfo> resolveServers(String filter) {
|
||||||
|
if (filter == null || filter.isBlank()) return registry.getServers();
|
||||||
|
ServerInfo exact = registry.getServer(filter);
|
||||||
|
if (exact != null) return List.of(exact);
|
||||||
|
return registry.getServersByRole(filter);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package net.kewwbec.networkcore.command.network;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.component.Ref;
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
|
import com.hypixel.hytale.server.core.Message;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||||
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.World;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.networkcore.NetworkCorePermissions;
|
||||||
|
import net.kewwbec.networkcore.api.ServerInfo;
|
||||||
|
import net.kewwbec.networkcore.api.ServerRegistry;
|
||||||
|
import net.kewwbec.networkcore.network.LockService;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import java.awt.Color;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /lockserver <on|off|status> [serverId] [reason...]
|
||||||
|
*
|
||||||
|
* - on / lock: lock the target server (default: this server). Drains non-bypass players to a
|
||||||
|
* lobby (or any other server, falling back to disconnect).
|
||||||
|
* - off / unlock: unlock the target server.
|
||||||
|
* - status: print this server's current lock state.
|
||||||
|
*/
|
||||||
|
public final class LockServerCommand extends AbstractPlayerCommand {
|
||||||
|
|
||||||
|
private final LockService locks;
|
||||||
|
private final ServerRegistry registry;
|
||||||
|
private final String selfServerId;
|
||||||
|
|
||||||
|
private final RequiredArg<String> actionArg = withRequiredArg("action", "on | off | status", ArgTypes.STRING);
|
||||||
|
private final OptionalArg<String> targetArg = withOptionalArg("server", "Server id (default: this server)", ArgTypes.STRING);
|
||||||
|
private final OptionalArg<String> reasonArg = withOptionalArg("reason", "Lock reason (optional)", ArgTypes.GREEDY_STRING);
|
||||||
|
|
||||||
|
public LockServerCommand(@Nonnull LockService locks,
|
||||||
|
@Nonnull ServerRegistry registry,
|
||||||
|
@Nonnull String selfServerId) {
|
||||||
|
super("lockserver", "Lock or unlock a server to non-bypass players");
|
||||||
|
this.locks = locks;
|
||||||
|
this.registry = registry;
|
||||||
|
this.selfServerId = selfServerId;
|
||||||
|
requirePermission(NetworkCorePermissions.LOCKSERVER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected boolean canGeneratePermission() { return false; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender, @Nonnull World world) {
|
||||||
|
String action = actionArg.get(ctx);
|
||||||
|
if (action == null) {
|
||||||
|
ctx.sendMessage(Message.raw("Usage: /lockserver <on|off|status> [serverId] [reason...]").color(Color.YELLOW));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String target = targetArg.get(ctx);
|
||||||
|
String reason = reasonArg.get(ctx);
|
||||||
|
String resolvedTarget = (target == null || target.isBlank()) ? selfServerId : target;
|
||||||
|
|
||||||
|
switch (action.toLowerCase()) {
|
||||||
|
case "on", "lock" -> doLock(ctx, sender, resolvedTarget, reason);
|
||||||
|
case "off", "unlock" -> doUnlock(ctx, sender, resolvedTarget);
|
||||||
|
case "status" -> doStatus(ctx);
|
||||||
|
default -> ctx.sendMessage(Message.raw("Unknown action '" + action + "'. Use on, off, or status.").color(Color.YELLOW));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doLock(@Nonnull CommandContext ctx, @Nonnull PlayerRef sender, @Nonnull String targetId, String reason) {
|
||||||
|
ServerInfo info = registry.getServer(targetId);
|
||||||
|
if (info == null && !targetId.equals(selfServerId)) {
|
||||||
|
ctx.sendMessage(Message.raw("Unknown server id '" + targetId + "'.").color(Color.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetId.equals(selfServerId)) {
|
||||||
|
locks.setLocal(true, reason, sender.getUsername());
|
||||||
|
ctx.sendMessage(Message.raw("This server is now LOCKED.").color(Color.GREEN));
|
||||||
|
} else {
|
||||||
|
locks.requestRemote(targetId, true, reason, sender.getUsername());
|
||||||
|
ctx.sendMessage(Message.raw("Lock request sent to " + targetId + ".").color(Color.GREEN));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doUnlock(@Nonnull CommandContext ctx, @Nonnull PlayerRef sender, @Nonnull String targetId) {
|
||||||
|
ServerInfo info = registry.getServer(targetId);
|
||||||
|
if (info == null && !targetId.equals(selfServerId)) {
|
||||||
|
ctx.sendMessage(Message.raw("Unknown server id '" + targetId + "'.").color(Color.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetId.equals(selfServerId)) {
|
||||||
|
locks.setLocal(false, null, sender.getUsername());
|
||||||
|
ctx.sendMessage(Message.raw("This server is now UNLOCKED.").color(Color.GREEN));
|
||||||
|
} else {
|
||||||
|
locks.requestRemote(targetId, false, null, sender.getUsername());
|
||||||
|
ctx.sendMessage(Message.raw("Unlock request sent to " + targetId + ".").color(Color.GREEN));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doStatus(@Nonnull CommandContext ctx) {
|
||||||
|
ctx.sendMessage(Message.raw("=== Lock status (this server) ===").color(Color.YELLOW));
|
||||||
|
ctx.sendMessage(Message.raw("Server: " + selfServerId).color(Color.WHITE));
|
||||||
|
if (locks.isLocked()) {
|
||||||
|
String r = locks.lockReason();
|
||||||
|
String by = locks.lockedByName();
|
||||||
|
ctx.sendMessage(Message.raw("State: LOCKED").color(Color.RED));
|
||||||
|
if (r != null && !r.isBlank()) ctx.sendMessage(Message.raw("Reason: " + r).color(Color.WHITE));
|
||||||
|
if (by != null && !by.isBlank()) ctx.sendMessage(Message.raw("Locked by: " + by).color(Color.WHITE));
|
||||||
|
} else {
|
||||||
|
ctx.sendMessage(Message.raw("State: open").color(Color.GREEN));
|
||||||
|
}
|
||||||
|
ctx.sendMessage(Message.raw("Status of other servers is not tracked in v1. Run /lockserver status on each.").color(Color.GRAY));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package net.kewwbec.networkcore.command.network;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.component.Ref;
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
|
import com.hypixel.hytale.server.core.Message;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||||
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
|
import com.hypixel.hytale.server.core.universe.Universe;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.World;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.networkcore.NetworkCorePermissions;
|
||||||
|
import net.kewwbec.networkcore.api.PlayerLocation;
|
||||||
|
import net.kewwbec.networkcore.api.PlayerPresence;
|
||||||
|
import net.kewwbec.networkcore.api.ServerInfo;
|
||||||
|
import net.kewwbec.networkcore.api.ServerRegistry;
|
||||||
|
import net.kewwbec.networkcore.network.RefRequestService;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /sendall <source> <target>
|
||||||
|
*
|
||||||
|
* Sends every player on the source server(s) to the target server.
|
||||||
|
*
|
||||||
|
* Each argument resolves with this priority:
|
||||||
|
* 1. literal "here" (source only) - this server
|
||||||
|
* 2. exact server id
|
||||||
|
* 3. role name (any server with that role)
|
||||||
|
*
|
||||||
|
* If source resolves to a role, every server with that role is drained.
|
||||||
|
* Target must resolve to a single server; if a role matches multiple, the least-loaded referrable
|
||||||
|
* one is picked. Players already on the target server are skipped.
|
||||||
|
*/
|
||||||
|
public final class SendAllCommand extends AbstractPlayerCommand {
|
||||||
|
|
||||||
|
private final ServerRegistry registry;
|
||||||
|
private final PlayerPresence presence;
|
||||||
|
private final RefRequestService refRequest;
|
||||||
|
private final String selfServerId;
|
||||||
|
|
||||||
|
private final RequiredArg<String> sourceArg = withRequiredArg("source", "here, server id, or role", ArgTypes.STRING);
|
||||||
|
private final RequiredArg<String> targetArg = withRequiredArg("target", "Server id or role", ArgTypes.STRING);
|
||||||
|
|
||||||
|
public SendAllCommand(@Nonnull ServerRegistry registry,
|
||||||
|
@Nonnull PlayerPresence presence,
|
||||||
|
@Nonnull RefRequestService refRequest,
|
||||||
|
@Nonnull String selfServerId) {
|
||||||
|
super("sendall", "Send every player from a server/role to another server/role");
|
||||||
|
this.registry = registry;
|
||||||
|
this.presence = presence;
|
||||||
|
this.refRequest = refRequest;
|
||||||
|
this.selfServerId = selfServerId;
|
||||||
|
requirePermission(NetworkCorePermissions.SENDALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override protected boolean canGeneratePermission() { return false; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender, @Nonnull World world) {
|
||||||
|
String sourceRaw = sourceArg.get(ctx);
|
||||||
|
String targetRaw = targetArg.get(ctx);
|
||||||
|
if (sourceRaw == null || targetRaw == null) {
|
||||||
|
ctx.sendMessage(Message.raw("Usage: /sendall <here|serverId|role> <serverId|role>").color(Color.YELLOW));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> sourceServers = resolveSource(sourceRaw);
|
||||||
|
if (sourceServers.isEmpty()) {
|
||||||
|
ctx.sendMessage(Message.raw("No source server matches '" + sourceRaw + "'.").color(Color.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerInfo target = resolveTarget(targetRaw);
|
||||||
|
if (target == null) {
|
||||||
|
ctx.sendMessage(Message.raw("No target server matches '" + targetRaw + "'.").color(Color.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!target.isReferrable()) {
|
||||||
|
ctx.sendMessage(Message.raw("Target server " + target.id() + " has no public address configured.").color(Color.RED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceServers.remove(target.id());
|
||||||
|
if (sourceServers.isEmpty()) {
|
||||||
|
ctx.sendMessage(Message.raw("Source and target resolve to the same server; nothing to do.").color(Color.YELLOW));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int localCount = 0;
|
||||||
|
int remoteCount = 0;
|
||||||
|
Universe universe = Universe.get();
|
||||||
|
for (String srcId : sourceServers) {
|
||||||
|
Collection<PlayerLocation> on = presence.onServer(srcId);
|
||||||
|
for (PlayerLocation pl : on) {
|
||||||
|
if (srcId.equals(selfServerId) && universe != null) {
|
||||||
|
PlayerRef local = universe.getPlayer(pl.uuid());
|
||||||
|
if (local != null) {
|
||||||
|
if (refRequest.referLocal(local, target)) localCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refRequest.requestRemote(pl.uuid(), target);
|
||||||
|
remoteCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int total = localCount + remoteCount;
|
||||||
|
ctx.sendMessage(Message.raw(
|
||||||
|
"Sending " + total + " player(s) from " + sourceServers + " to " + target.id()
|
||||||
|
+ " (" + localCount + " local, " + remoteCount + " remote)."
|
||||||
|
).color(Color.GREEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private Set<String> resolveSource(@Nonnull String input) {
|
||||||
|
if ("here".equalsIgnoreCase(input)) {
|
||||||
|
return new LinkedHashSet<>(List.of(selfServerId));
|
||||||
|
}
|
||||||
|
Set<String> out = new LinkedHashSet<>();
|
||||||
|
ServerInfo exact = registry.getServer(input);
|
||||||
|
if (exact != null) {
|
||||||
|
out.add(exact.id());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (ServerInfo s : registry.getServersByRole(input)) {
|
||||||
|
out.add(s.id());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private ServerInfo resolveTarget(@Nonnull String input) {
|
||||||
|
ServerInfo exact = registry.getServer(input);
|
||||||
|
if (exact != null) return exact;
|
||||||
|
List<ServerInfo> byRole = new ArrayList<>(registry.getServersByRole(input));
|
||||||
|
return byRole.stream()
|
||||||
|
.filter(ServerInfo::isReferrable)
|
||||||
|
.min(Comparator.comparingInt(ServerInfo::players))
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package net.kewwbec.networkcore.network;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.server.core.Message;
|
||||||
|
import com.hypixel.hytale.server.core.event.events.player.PlayerSetupConnectEvent;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels PlayerSetupConnectEvent if this server is locked and the connecting player lacks the
|
||||||
|
* LOCKSERVER_BYPASS permission.
|
||||||
|
*/
|
||||||
|
public final class LockEnforcer {
|
||||||
|
|
||||||
|
private final LockService locks;
|
||||||
|
|
||||||
|
public LockEnforcer(@Nonnull LockService locks) {
|
||||||
|
this.locks = locks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onSetupConnect(@Nonnull PlayerSetupConnectEvent event) {
|
||||||
|
if (!locks.isLocked()) return;
|
||||||
|
if (locks.hasBypass(event.getUuid())) return;
|
||||||
|
event.setCancelled(true);
|
||||||
|
String reason = locks.lockReason();
|
||||||
|
String text = (reason != null && !reason.isBlank())
|
||||||
|
? "Server locked: " + reason
|
||||||
|
: "Server is locked. Please try again later.";
|
||||||
|
event.setReason(Message.raw(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package net.kewwbec.networkcore.network;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-server lock/unlock instruction. Only the server whose id matches targetServerId acts.
|
||||||
|
*/
|
||||||
|
public final class LockServerPayload {
|
||||||
|
|
||||||
|
public static final String CHANNEL = "network.lock";
|
||||||
|
|
||||||
|
public String targetServerId;
|
||||||
|
public boolean lock;
|
||||||
|
public String reason;
|
||||||
|
public String issuedByName;
|
||||||
|
public String fromServerId;
|
||||||
|
|
||||||
|
public LockServerPayload() {}
|
||||||
|
|
||||||
|
public LockServerPayload(@Nonnull String targetServerId,
|
||||||
|
boolean lock,
|
||||||
|
@Nullable String reason,
|
||||||
|
@Nullable String issuedByName,
|
||||||
|
@Nullable String fromServerId) {
|
||||||
|
this.targetServerId = targetServerId;
|
||||||
|
this.lock = lock;
|
||||||
|
this.reason = reason;
|
||||||
|
this.issuedByName = issuedByName;
|
||||||
|
this.fromServerId = fromServerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package net.kewwbec.networkcore.network;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
|
import com.hypixel.hytale.server.core.Message;
|
||||||
|
import com.hypixel.hytale.server.core.permissions.PermissionsModule;
|
||||||
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
|
import com.hypixel.hytale.server.core.universe.Universe;
|
||||||
|
import net.kewwbec.networkcore.NetworkCorePermissions;
|
||||||
|
import net.kewwbec.networkcore.api.MessageBus;
|
||||||
|
import net.kewwbec.networkcore.api.ServerInfo;
|
||||||
|
import net.kewwbec.networkcore.api.ServerRegistry;
|
||||||
|
import net.kewwbec.networkcore.internal.LobbyPicker;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the local lock state for this server: whether non-bypass players can connect.
|
||||||
|
*
|
||||||
|
* Persistence: a JSON file in the plugin data dir. Lock state survives restarts so an admin's
|
||||||
|
* lock isn't accidentally cleared when the server reboots.
|
||||||
|
*
|
||||||
|
* Bus: subscribes to {@link LockServerPayload#CHANNEL}; if the message targets this server id,
|
||||||
|
* applies the change locally. The {@link #setLocked(boolean, String, String)} method handles both
|
||||||
|
* the local-command case (no bus publish) and post-bus-receive (also no further publish).
|
||||||
|
*
|
||||||
|
* Drain: when transitioning to LOCKED, walks online non-bypass players and refers each to a lobby
|
||||||
|
* (via {@link LobbyPicker}); falls back to any other referrable server. If nothing is available,
|
||||||
|
* the player is disconnected with the lock reason.
|
||||||
|
*/
|
||||||
|
public final class LockService {
|
||||||
|
|
||||||
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
|
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
|
||||||
|
private final String selfServerId;
|
||||||
|
private final ServerRegistry registry;
|
||||||
|
private final LobbyPicker lobbyPicker;
|
||||||
|
private final MessageBus bus;
|
||||||
|
private final Path stateFile;
|
||||||
|
|
||||||
|
private final AtomicBoolean locked = new AtomicBoolean(false);
|
||||||
|
@Nullable private volatile String lockReason;
|
||||||
|
@Nullable private volatile String lockedByName;
|
||||||
|
@Nullable private MessageBus.Subscription sub;
|
||||||
|
|
||||||
|
public LockService(@Nonnull String selfServerId,
|
||||||
|
@Nonnull ServerRegistry registry,
|
||||||
|
@Nonnull LobbyPicker lobbyPicker,
|
||||||
|
@Nonnull MessageBus bus,
|
||||||
|
@Nonnull Path dataDirectory) {
|
||||||
|
this.selfServerId = selfServerId;
|
||||||
|
this.registry = registry;
|
||||||
|
this.lobbyPicker = lobbyPicker;
|
||||||
|
this.bus = bus;
|
||||||
|
this.stateFile = dataDirectory.resolve("lock-state.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start() {
|
||||||
|
loadFromDisk();
|
||||||
|
sub = bus.subscribe(LockServerPayload.CHANNEL, LockServerPayload.class, this::onBus);
|
||||||
|
if (locked.get()) {
|
||||||
|
LOGGER.at(Level.INFO).log("Server is LOCKED (restored from disk). Reason: %s", lockReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stop() {
|
||||||
|
if (sub != null) {
|
||||||
|
try { sub.close(); } catch (RuntimeException ignored) {}
|
||||||
|
sub = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isLocked() {
|
||||||
|
return locked.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public String lockReason() {
|
||||||
|
return lockReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public String lockedByName() {
|
||||||
|
return lockedByName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasBypass(@Nonnull UUID uuid) {
|
||||||
|
PermissionsModule module = PermissionsModule.get();
|
||||||
|
return module != null && module.hasPermission(uuid, NetworkCorePermissions.LOCKSERVER_BYPASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock or unlock another server in the network. Returns immediately; the target server applies
|
||||||
|
* the change asynchronously after receiving the bus message.
|
||||||
|
*/
|
||||||
|
public void requestRemote(@Nonnull String targetServerId, boolean lock,
|
||||||
|
@Nullable String reason, @Nullable String issuedByName) {
|
||||||
|
bus.publish(LockServerPayload.CHANNEL, new LockServerPayload(
|
||||||
|
targetServerId, lock, reason, issuedByName, selfServerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock or unlock THIS server. Persists, drains, and broadcasts (so other plugins or future
|
||||||
|
* status readers can observe even though only this server enforces).
|
||||||
|
*/
|
||||||
|
public void setLocal(boolean lock, @Nullable String reason, @Nullable String issuedByName) {
|
||||||
|
applyLocal(lock, reason, issuedByName);
|
||||||
|
bus.publish(LockServerPayload.CHANNEL, new LockServerPayload(
|
||||||
|
selfServerId, lock, reason, issuedByName, selfServerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyLocal(boolean lock, @Nullable String reason, @Nullable String issuedByName) {
|
||||||
|
boolean was = locked.getAndSet(lock);
|
||||||
|
this.lockReason = reason;
|
||||||
|
this.lockedByName = issuedByName;
|
||||||
|
saveToDisk();
|
||||||
|
if (lock && !was) {
|
||||||
|
LOGGER.at(Level.INFO).log("Server LOCKED by %s. Reason: %s", issuedByName, reason);
|
||||||
|
drainNonBypass(reason);
|
||||||
|
} else if (!lock && was) {
|
||||||
|
LOGGER.at(Level.INFO).log("Server UNLOCKED by %s.", issuedByName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onBus(@Nonnull LockServerPayload p) {
|
||||||
|
if (p.targetServerId == null || !p.targetServerId.equals(selfServerId)) return;
|
||||||
|
if (p.fromServerId != null && p.fromServerId.equals(selfServerId)) return; // our own echo
|
||||||
|
applyLocal(p.lock, p.reason, p.issuedByName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drainNonBypass(@Nullable String reason) {
|
||||||
|
Universe universe = Universe.get();
|
||||||
|
if (universe == null) return;
|
||||||
|
|
||||||
|
Message kickMsg = Message.raw(reason != null && !reason.isBlank()
|
||||||
|
? "Server locked: " + reason
|
||||||
|
: "Server locked. Please reconnect later.");
|
||||||
|
|
||||||
|
List<PlayerRef> snapshot = new ArrayList<>(universe.getPlayers());
|
||||||
|
ServerInfo lobby = lobbyPicker.pick(selfServerId);
|
||||||
|
ServerInfo fallback = pickFallback(lobby);
|
||||||
|
|
||||||
|
for (PlayerRef ref : snapshot) {
|
||||||
|
try {
|
||||||
|
if (hasBypass(ref.getUuid())) continue;
|
||||||
|
ServerInfo dest = lobby;
|
||||||
|
if (dest == null) dest = fallback;
|
||||||
|
if (dest != null && dest.isReferrable()) {
|
||||||
|
try {
|
||||||
|
ref.referToServer(dest.host(), dest.port());
|
||||||
|
continue;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
LOGGER.at(Level.WARNING).log("Drain refer of %s failed: %s", ref.getUsername(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try { ref.getPacketHandler().disconnect(kickMsg); } catch (RuntimeException ignored) {}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e))
|
||||||
|
.log("Drain failed for %s", ref.getUsername());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private ServerInfo pickFallback(@Nullable ServerInfo excludeLobby) {
|
||||||
|
List<ServerInfo> pool = new ArrayList<>();
|
||||||
|
for (ServerInfo s : registry.getServers()) {
|
||||||
|
if (s.id().equals(selfServerId)) continue;
|
||||||
|
if (excludeLobby != null && s.id().equals(excludeLobby.id())) continue;
|
||||||
|
if (!s.isReferrable()) continue;
|
||||||
|
pool.add(s);
|
||||||
|
}
|
||||||
|
if (pool.isEmpty()) return null;
|
||||||
|
pool.sort(Comparator.comparingInt(ServerInfo::players));
|
||||||
|
return pool.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadFromDisk() {
|
||||||
|
if (!Files.exists(stateFile)) return;
|
||||||
|
try {
|
||||||
|
String json = Files.readString(stateFile, StandardCharsets.UTF_8);
|
||||||
|
State s = GSON.fromJson(json, State.class);
|
||||||
|
if (s == null) return;
|
||||||
|
locked.set(s.locked);
|
||||||
|
lockReason = s.reason;
|
||||||
|
lockedByName = s.lockedByName;
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOGGER.at(Level.WARNING).log("Failed to read lock state: %s", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveToDisk() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(stateFile.getParent());
|
||||||
|
State s = new State();
|
||||||
|
s.locked = locked.get();
|
||||||
|
s.reason = lockReason;
|
||||||
|
s.lockedByName = lockedByName;
|
||||||
|
Files.writeString(stateFile, GSON.toJson(s), StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOGGER.at(Level.WARNING).log("Failed to write lock state: %s", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class State {
|
||||||
|
boolean locked;
|
||||||
|
String reason;
|
||||||
|
String lockedByName;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user