diff --git a/src/main/java/net/kewwbec/networkcore/NetworkCore.java b/src/main/java/net/kewwbec/networkcore/NetworkCore.java index 17ba341..d13b211 100644 --- a/src/main/java/net/kewwbec/networkcore/NetworkCore.java +++ b/src/main/java/net/kewwbec/networkcore/NetworkCore.java @@ -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; diff --git a/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java b/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java index faefeae..e9723e7 100644 --- a/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java +++ b/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java @@ -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() { } diff --git a/src/main/java/net/kewwbec/networkcore/command/network/GlistCommand.java b/src/main/java/net/kewwbec/networkcore/command/network/GlistCommand.java index 4de76c8..72c1d75 100644 --- a/src/main/java/net/kewwbec/networkcore/command/network/GlistCommand.java +++ b/src/main/java/net/kewwbec/networkcore/command/network/GlistCommand.java @@ -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 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 store, @Nonnull Ref ref, @Nonnull PlayerRef sender, @Nonnull World world) { - Collection 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 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 includedIds = new LinkedHashSet<>(); + for (ServerInfo s : servers) includedIds.add(s.id()); Map> 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> 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 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); + } } diff --git a/src/main/java/net/kewwbec/networkcore/command/network/LockServerCommand.java b/src/main/java/net/kewwbec/networkcore/command/network/LockServerCommand.java new file mode 100644 index 0000000..c3a2570 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/command/network/LockServerCommand.java @@ -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 actionArg = withRequiredArg("action", "on | off | status", ArgTypes.STRING); + private final OptionalArg targetArg = withOptionalArg("server", "Server id (default: this server)", ArgTypes.STRING); + private final OptionalArg 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 store, @Nonnull Ref ref, @Nonnull PlayerRef sender, @Nonnull World world) { + String action = actionArg.get(ctx); + if (action == null) { + ctx.sendMessage(Message.raw("Usage: /lockserver [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)); + } +} diff --git a/src/main/java/net/kewwbec/networkcore/command/network/SendAllCommand.java b/src/main/java/net/kewwbec/networkcore/command/network/SendAllCommand.java new file mode 100644 index 0000000..f329cd6 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/command/network/SendAllCommand.java @@ -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 sourceArg = withRequiredArg("source", "here, server id, or role", ArgTypes.STRING); + private final RequiredArg 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 store, @Nonnull Ref 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 ").color(Color.YELLOW)); + return; + } + + Set 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 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 resolveSource(@Nonnull String input) { + if ("here".equalsIgnoreCase(input)) { + return new LinkedHashSet<>(List.of(selfServerId)); + } + Set 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 byRole = new ArrayList<>(registry.getServersByRole(input)); + return byRole.stream() + .filter(ServerInfo::isReferrable) + .min(Comparator.comparingInt(ServerInfo::players)) + .orElse(null); + } +} diff --git a/src/main/java/net/kewwbec/networkcore/network/LockEnforcer.java b/src/main/java/net/kewwbec/networkcore/network/LockEnforcer.java new file mode 100644 index 0000000..e417d9d --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/network/LockEnforcer.java @@ -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)); + } +} diff --git a/src/main/java/net/kewwbec/networkcore/network/LockServerPayload.java b/src/main/java/net/kewwbec/networkcore/network/LockServerPayload.java new file mode 100644 index 0000000..7ac2ef9 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/network/LockServerPayload.java @@ -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; + } +} diff --git a/src/main/java/net/kewwbec/networkcore/network/LockService.java b/src/main/java/net/kewwbec/networkcore/network/LockService.java new file mode 100644 index 0000000..42b3faa --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/network/LockService.java @@ -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 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 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; + } +}