filtering + server lock

This commit is contained in:
2026-05-27 09:06:04 -04:00
parent 2c644f02f0
commit 45934b0706
8 changed files with 610 additions and 4 deletions
@@ -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.player.PlayerConnectEvent;
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.JavaPluginInit;
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.GlistCommand;
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.config.ConfigLoader;
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.PrometheusExporter;
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.ShutdownHandoff;
import net.kewwbec.networkcore.presence.PlayerPresenceService;
@@ -61,6 +66,7 @@ public final class NetworkCore extends JavaPlugin {
@Nullable private PlayerPresenceService presenceService;
@Nullable private AlertService alertService;
@Nullable private RefRequestService refRequestService;
@Nullable private LockService lockService;
@Nullable private ShutdownHandoff shutdownHandoff;
public NetworkCore(@Nonnull JavaPluginInit init) {
@@ -157,6 +163,11 @@ public final class NetworkCore extends JavaPlugin {
LobbyPicker lobbyPicker = new LobbyPicker(serverRegistry, config.server);
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(
(short) (ShutdownEvent.DISCONNECT_PLAYERS - 1),
ShutdownEvent.class,
@@ -170,6 +181,8 @@ public final class NetworkCore extends JavaPlugin {
getCommandRegistry().registerCommand(new GlistCommand(serverRegistry, presenceService));
getCommandRegistry().registerCommand(new HubCommand(lobbyPicker, refRequestService, serverId));
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");
}
@@ -177,6 +190,10 @@ public final class NetworkCore extends JavaPlugin {
@Override
protected void shutdown() {
shutdownHandoff = null;
if (lockService != null) {
try { lockService.stop(); } catch (RuntimeException ignored) {}
lockService = null;
}
if (refRequestService != null) {
try { refRequestService.stop(); } catch (RuntimeException ignored) {}
refRequestService = null;
@@ -11,6 +11,9 @@ public final class NetworkCorePermissions {
public static final String GLIST = ROOT + ".glist";
public static final String HUB = ROOT + ".hub";
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() {
}
@@ -4,6 +4,8 @@ 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.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;
@@ -19,16 +21,25 @@ import java.awt.Color;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
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 {
private final ServerRegistry registry;
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) {
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.presence = presence;
requirePermission(NetworkCorePermissions.GLIST);
@@ -38,16 +49,32 @@ public final class GlistCommand extends AbstractPlayerCommand {
@Override
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();
ctx.sendMessage(Message.raw("=== Network ===").color(Color.YELLOW));
ctx.sendMessage(Message.raw("Servers: " + servers.size() + " | Online: " + presence.onlineCount()).color(Color.WHITE));
String filter = filterArg.get(ctx);
Collection<ServerInfo> servers = resolveServers(filter);
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<>();
for (ServerInfo s : servers) grouped.put(s.id(), new ArrayList<>());
int filteredOnline = 0;
for (PlayerLocation loc : presence.all()) {
if (!includedIds.contains(loc.serverId())) continue;
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()) {
ServerInfo s = registry.getServer(e.getKey());
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 &lt;on|off|status&gt; [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 &lt;source&gt; &lt;target&gt;
*
* 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;
}
}