diff --git a/docs/03_Configuration.md b/docs/03_Configuration.md index aefb940..177aad7 100644 --- a/docs/03_Configuration.md +++ b/docs/03_Configuration.md @@ -10,7 +10,11 @@ { "server": { "id": null, - "role": "lobby" + "role": "lobby", + "public_address": null, + "public_port": null, + "lobby_role": "lobby", + "lobby_role_prefix": "lobby_" }, "redis": { "url": null, @@ -59,6 +63,10 @@ |---|---|---| | `id` | `null` | Optional fixed server id. If null, auto-generated as `-<8 hex chars>` (e.g. `lobby-3f2a91bd`) on every boot. Set this when you want a stable id across restarts. | | `role` | `"lobby"` | Logical role of this server. Used to filter via `ServerRegistry.getServersByRole(...)`. Examples: `lobby`, `bridge_duel`, `arena`. Use snake_case. | +| `public_address` | `null` | Hostname/IP other servers will hand to players when transferring them HERE via `/send` / `/hub`. Null = auto-detect system hostname. **Set this explicitly on hosted setups** where auto-detect gives the wrong answer (typically `127.0.0.1` or a private LAN name). | +| `public_port` | `null` | Port for the same. Null = Hytale's `DEFAULT_PORT` (currently 5520). | +| `lobby_role` | `"lobby"` | Role string `/hub` and shutdown handoff look for first. | +| `lobby_role_prefix` | `"lobby_"` | If no server matches `lobby_role`, accept any role starting with this prefix (e.g. `lobby_eu`, `lobby_na`). | ### redis diff --git a/docs/08_Commands.md b/docs/08_Commands.md index 23189fa..814ecc6 100644 --- a/docs/08_Commands.md +++ b/docs/08_Commands.md @@ -13,6 +13,12 @@ Every subcommand is gated by a permission node, checked via Hytale's `Permission | `/networkcore status` | `networkcore.status` | | `/networkcore servers` | `networkcore.servers` | | `/networkcore publish` | `networkcore.publish` | +| `/alert ` | `networkcore.alert` | +| `/glist` | `networkcore.glist` | +| `/hub` | `networkcore.hub` | +| `/send ` | `networkcore.send` | + +The four "BungeeCord-style" commands at the bottom are documented in detail in [11_Network_Features.md](11_Network_Features.md). You can grant the whole namespace with `networkcore.*` (Hytale's permission system handles wildcards). A player without the relevant node sees Hytale's standard `You do not have permission for this command.` message - the command system itself blocks execution before our code runs. diff --git a/docs/11_Network_Features.md b/docs/11_Network_Features.md new file mode 100644 index 0000000..d31f581 --- /dev/null +++ b/docs/11_Network_Features.md @@ -0,0 +1,85 @@ +# Network Features + +BungeeCord-style admin commands and cross-server player movement, built on top of NetworkCore's MessageBus and ServerRegistry. + +## What's in this layer + +| Piece | Purpose | +|---|---| +| `PlayerPresence` API + `PlayerPresenceService` | Network-wide "who is on which server" tracking. | +| `RefRequestService` | Cross-server "please send this player to host:port" wrapper around `PlayerRef.referToServer`. | +| `AlertService` | Cross-server broadcast to all players. | +| `LobbyPicker` | Finds a referrable lobby (least-loaded), with role-prefix fallback. | +| `ShutdownHandoff` | On graceful stop, walks local players and refers each to a lobby before Redis closes. | +| Commands | `/alert`, `/glist`, `/hub`, `/send` | + +## Commands + +| Command | Permission | What it does | +|---|---|---| +| `/alert ` | `networkcore.alert` | Single-token message broadcast to every player on every server. (Multi-word reasons need the UI - see Staff's pattern.) | +| `/glist` | `networkcore.glist` | Lists every server + players currently on each, from the local `PlayerPresence` cache. | +| `/hub` | `networkcore.hub` | Refers the caller to a lobby. Picks least-loaded. Errors if no lobby is available. | +| `/send ` | `networkcore.send` | Refers the named player (online anywhere) to a server. `server` can be a literal server id or a role (picks least-loaded with that role). | + +## How the transfer actually works + +Hytale ships `PlayerRef.referToServer(host, port)` which sends a `ClientReferral` packet to the player's client. The client disconnects from the current server and connects to the new host:port. Hytale's own `/refer` command does the same thing for one player at a time on the same server. + +For a player who's on **this** server, we call `referToServer` directly. + +For a player on a **different** server, we publish a `RefRequest` on the bus. Every server's `RefRequestService` listens; the one that has the player locally does the call. The other servers ignore the request. + +This whole flow takes a few hundred ms end to end: +1. `/send Bob lobby` on server A (Bob is on arena-3) → A publishes RefRequest +2. arena-3 receives → calls `bob.referToServer("lobby-1.kweebec.com", 5520)` +3. Bob's client gets the ClientReferral, drops the connection to arena-3, opens a new one to lobby-1 +4. lobby-1's connect flow runs as normal + +## Public address setup + +Every server needs to know its own public-facing host/port (what other servers should hand to clients in the referral packet). Configure via `server.public_address` + `server.public_port` in [config.json](03_Configuration.md#server). + +If null, we auto-detect via Java's `InetAddress.getLocalHost().getHostName()`. On dev boxes that's usually fine. On cloud / hosted setups (Apex etc.) it almost certainly is NOT - you'll get an internal name that clients can't reach. **Set it explicitly.** + +You can confirm it parsed correctly: boot log line `Public network address resolved to :`. Also `/networkcore servers` shows host/port for each server in the network now. + +A server with no `host` in its `ServerInfo` (i.e. its heartbeat didn't carry one) is treated as "not referrable" - `/hub` and `/send` will skip it. + +## Graceful shutdown handoff + +In `NetworkCore.shutdown()`, before closing Redis, we walk `Universe.getPlayers()` and `referToServer(...)` each one to a lobby (picked via `LobbyPicker`). + +This means a properly-shut-down server doesn't drop its players - they get teleported away. It happens BEFORE the goodbye announce + Redis close, so the LobbyPicker can still see other servers in the registry. + +**Ungraceful kills** (kill -9, OOM, network partition, hardware failure) bypass this entirely. Players see Hytale's normal disconnect screen and reconnect manually. This is unavoidable without a proxy in front - there's no way for the dying server to refer players after it's dead. + +To make the most of this: +- Use systemd / supervisord / similar to send SIGTERM and wait before SIGKILL. Hytale's shutdown hook handles SIGTERM. +- Don't `docker kill` containers - `docker stop` gives a grace period. +- For deploy rollouts, drain (run `/send-all-to lobby` or similar) before shutdown to be doubly sure. + +## Presence cache + +`PlayerPresenceService` keeps an in-memory `UUID → PlayerLocation` map. Maintained by: + +- Local `PlayerConnectEvent` / `PlayerDisconnectEvent` → publish on `network:presence` +- Remote presence events on the same channel → update local map +- `ServerLeftNetworkEvent` (server dropped from registry) → purge all players known to be on that server + +On server boot, we re-publish our local roster so other servers refresh their view of us after our restart. Type: `KIND_SYNC`. + +The cache is unbounded but bounded in practice by total online player count. No TTL - entries live until a leave event drops them. A lost leave event leaves a stale entry until that player connects somewhere else (which fires a new join) or until the holding server drops from the registry. + +## What this layer deliberately doesn't do + +- **Per-world routing.** /hub picks a server, not a world within one. If you want world-aware routing, layer it on top. +- **Cross-region picking.** No notion of latency / geography. If you need region-aware lobby selection, sort `LobbyPicker` candidates by a region tag and a `getRegion()` method on `ServerInfo`. +- **Queue handling.** A `/send` to a full server just succeeds in queuing the referral; the destination might reject the connection. No retry, no queue, no "you're #3 in line". +- **Mid-flight player tracking.** Once we hand off a referral, we don't follow the player until they connect to the destination. There's a window of "between servers" where `presence.getLocation` returns the old server until the new one's connect event fires. +- **`/glist` filtering by group/role.** Easy follow-up; not in v1. +- **In-game UI for any of this.** Commands only. Build a HyUI hub later if you want. + +## Migration note for existing plugins + +`ServerInfo` got two new fields (`host`, `port`) and the constructor signature changed. Anything depending on the NetworkCore jar (Staff, Ranks) needs to be **rebuilt** against the new jar. Same `NoSuchMethodError` pattern we hit before with `Universe.getPlayers()`. diff --git a/docs/README.md b/docs/README.md index dc42c3e..929b314 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ If you're building a plugin that needs to talk to other servers, record stats, o 8. [08_Commands.md](08_Commands.md) - the /networkcore admin command 9. [09_Operations_And_Security.md](09_Operations_And_Security.md) - auth token, what's not secured, ops checks 10. [10_Database.md](10_Database.md) - shared MySQL pool (HikariCP) +11. [11_Network_Features.md](11_Network_Features.md) - /alert, /glist, /hub, /send + cross-server player movement ## Quick reference diff --git a/src/main/java/net/kewwbec/networkcore/NetworkCore.java b/src/main/java/net/kewwbec/networkcore/NetworkCore.java index 0bb95c4..17ba341 100644 --- a/src/main/java/net/kewwbec/networkcore/NetworkCore.java +++ b/src/main/java/net/kewwbec/networkcore/NetworkCore.java @@ -1,23 +1,38 @@ package net.kewwbec.networkcore; 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.plugin.JavaPlugin; import com.hypixel.hytale.server.core.plugin.JavaPluginInit; import net.kewwbec.networkcore.api.DatabaseService; import net.kewwbec.networkcore.api.MessageBus; import net.kewwbec.networkcore.api.MetricsService; +import net.kewwbec.networkcore.api.PlayerPresence; import net.kewwbec.networkcore.api.RpcClient; import net.kewwbec.networkcore.api.ServerRegistry; +import net.kewwbec.networkcore.api.events.ServerLeftNetworkEvent; import net.kewwbec.networkcore.bus.RedisMessageBus; 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.SendCommand; import net.kewwbec.networkcore.config.ConfigLoader; import net.kewwbec.networkcore.config.CoreConfig; import net.kewwbec.networkcore.db.MySqlDatabaseService; +import net.kewwbec.networkcore.internal.LobbyPicker; +import net.kewwbec.networkcore.internal.NetworkAddress; import net.kewwbec.networkcore.internal.ServerIdGenerator; import net.kewwbec.networkcore.internal.ServiceRegistry; 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.RefRequestService; +import net.kewwbec.networkcore.network.ShutdownHandoff; +import net.kewwbec.networkcore.presence.PlayerPresenceService; import net.kewwbec.networkcore.registry.RedisServerRegistry; import net.kewwbec.networkcore.rpc.RpcClientImpl; @@ -35,6 +50,7 @@ public final class NetworkCore extends JavaPlugin { private CoreConfig config; private String serverId; + private NetworkAddress publicAddress; @Nullable private MetricsServiceImpl metricsImpl; @Nullable private PrometheusExporter exporter; @@ -42,6 +58,10 @@ public final class NetworkCore extends JavaPlugin { @Nullable private RedisServerRegistry serverRegistry; @Nullable private RpcClientImpl rpcClient; @Nullable private MySqlDatabaseService database; + @Nullable private PlayerPresenceService presenceService; + @Nullable private AlertService alertService; + @Nullable private RefRequestService refRequestService; + @Nullable private ShutdownHandoff shutdownHandoff; public NetworkCore(@Nonnull JavaPluginInit init) { super(init); @@ -66,6 +86,7 @@ public final class NetworkCore extends JavaPlugin { this.serverId = (config.server.id == null || config.server.id.isBlank()) ? ServerIdGenerator.generate(config.server.role) : config.server.id; + this.publicAddress = NetworkAddress.resolve(config.server); if (config.metrics.enabled) { this.metricsImpl = new MetricsServiceImpl(); @@ -97,7 +118,7 @@ public final class NetworkCore extends JavaPlugin { return; } - this.serverRegistry = new RedisServerRegistry(messageBus, config.redis, serverId, config.server.role); + this.serverRegistry = new RedisServerRegistry(messageBus, config.redis, serverId, config.server.role, publicAddress); serverRegistry.start(); services.register(ServerRegistry.class, serverRegistry); @@ -120,11 +141,54 @@ public final class NetworkCore extends JavaPlugin { LOGGER.at(Level.INFO).log("MySQL disabled in config (mysql.enabled=false); no DatabaseService registered"); } + this.presenceService = new PlayerPresenceService(messageBus, serverId); + presenceService.start(); + services.register(PlayerPresence.class, presenceService); + getEventRegistry().registerGlobal(PlayerConnectEvent.class, presenceService::onConnect); + getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, presenceService::onDisconnect); + getEventRegistry().registerGlobal(ServerLeftNetworkEvent.class, presenceService::onServerLeft); + + this.alertService = new AlertService(messageBus, serverId); + alertService.start(); + + this.refRequestService = new RefRequestService(messageBus, serverId); + refRequestService.start(); + + LobbyPicker lobbyPicker = new LobbyPicker(serverRegistry, config.server); + this.shutdownHandoff = new ShutdownHandoff(lobbyPicker, serverId); + + getEventRegistry().registerGlobal( + (short) (ShutdownEvent.DISCONNECT_PLAYERS - 1), + ShutdownEvent.class, + event -> { + if (shutdownHandoff != null) { + try { shutdownHandoff.handoffAll(); } catch (RuntimeException ignored) {} + } + }); + + getCommandRegistry().registerCommand(new AlertCommand(alertService)); + getCommandRegistry().registerCommand(new GlistCommand(serverRegistry, presenceService)); + getCommandRegistry().registerCommand(new HubCommand(lobbyPicker, refRequestService, serverId)); + getCommandRegistry().registerCommand(new SendCommand(serverRegistry, presenceService, refRequestService)); + LOGGER.at(Level.INFO).log("NetworkCore started"); } @Override protected void shutdown() { + shutdownHandoff = null; + if (refRequestService != null) { + try { refRequestService.stop(); } catch (RuntimeException ignored) {} + refRequestService = null; + } + if (alertService != null) { + try { alertService.stop(); } catch (RuntimeException ignored) {} + alertService = null; + } + if (presenceService != null) { + try { presenceService.stop(); } catch (RuntimeException ignored) {} + presenceService = null; + } if (rpcClient != null) { try { rpcClient.close(); } catch (RuntimeException ignored) {} rpcClient = null; @@ -169,6 +233,8 @@ public final class NetworkCore extends JavaPlugin { public RpcClient getRpc() { return services.get(RpcClient.class); } @Nullable public DatabaseService getDatabase() { return services.get(DatabaseService.class); } + @Nullable + public PlayerPresence getPlayerPresence() { return services.get(PlayerPresence.class); } @Nonnull public CoreConfig getConfigSnapshot() { diff --git a/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java b/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java index fd5ccf3..faefeae 100644 --- a/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java +++ b/src/main/java/net/kewwbec/networkcore/NetworkCorePermissions.java @@ -7,6 +7,11 @@ public final class NetworkCorePermissions { public static final String SERVERS = ROOT + ".servers"; public static final String PUBLISH = ROOT + ".publish"; + public static final String ALERT = ROOT + ".alert"; + public static final String GLIST = ROOT + ".glist"; + public static final String HUB = ROOT + ".hub"; + public static final String SEND = ROOT + ".send"; + private NetworkCorePermissions() { } } diff --git a/src/main/java/net/kewwbec/networkcore/api/PlayerLocation.java b/src/main/java/net/kewwbec/networkcore/api/PlayerLocation.java new file mode 100644 index 0000000..36f12d5 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/PlayerLocation.java @@ -0,0 +1,12 @@ +package net.kewwbec.networkcore.api; + +import javax.annotation.Nonnull; +import java.util.UUID; + +public record PlayerLocation( + @Nonnull UUID uuid, + @Nonnull String username, + @Nonnull String serverId, + long sinceMillis +) { +} diff --git a/src/main/java/net/kewwbec/networkcore/api/PlayerPresence.java b/src/main/java/net/kewwbec/networkcore/api/PlayerPresence.java new file mode 100644 index 0000000..4b2b95d --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/PlayerPresence.java @@ -0,0 +1,39 @@ +package net.kewwbec.networkcore.api; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collection; +import java.util.UUID; + +/** + * Network-wide tracking of which player is on which server. Maintained by every server publishing + * presence change events on a shared MessageBus channel. + */ +public interface PlayerPresence { + + /** + * Returns the location of a player anywhere on the network, or null if not online anywhere known. + */ + @Nullable + PlayerLocation getLocation(@Nonnull UUID uuid); + + @Nullable + PlayerLocation findByName(@Nonnull String username); + + /** + * Snapshot of every player currently known to be online network-wide. + */ + @Nonnull + Collection all(); + + /** + * Snapshot of players known to be on the given server id. + */ + @Nonnull + Collection onServer(@Nonnull String serverId); + + /** + * Best-effort total count. + */ + int onlineCount(); +} diff --git a/src/main/java/net/kewwbec/networkcore/api/ServerInfo.java b/src/main/java/net/kewwbec/networkcore/api/ServerInfo.java index ec59225..2bfbe08 100644 --- a/src/main/java/net/kewwbec/networkcore/api/ServerInfo.java +++ b/src/main/java/net/kewwbec/networkcore/api/ServerInfo.java @@ -8,21 +8,23 @@ public final class ServerInfo { private final String id; private final String role; - @Nullable - private final String address; + @Nullable private final String host; + private final int port; private final int players; private final long startedAt; private final long lastSeen; public ServerInfo(@Nonnull String id, @Nonnull String role, - @Nullable String address, + @Nullable String host, + int port, int players, long startedAt, long lastSeen) { this.id = id; this.role = role; - this.address = address; + this.host = host; + this.port = port; this.players = players; this.startedAt = startedAt; this.lastSeen = lastSeen; @@ -30,30 +32,37 @@ public final class ServerInfo { @Nonnull public String id() { return id; } @Nonnull public String role() { return role; } - @Nullable public String address() { return address; } + @Nullable public String host() { return host; } + public int port() { return port; } public int players() { return players; } public long startedAt() { return startedAt; } public long lastSeen() { return lastSeen; } + /** True if host is set and port is in a sensible range, i.e. this server is referrable to. */ + public boolean isReferrable() { + return host != null && !host.isBlank() && port > 0 && port <= 65535; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof ServerInfo other)) return false; - return players == other.players + return port == other.port + && players == other.players && startedAt == other.startedAt && lastSeen == other.lastSeen && id.equals(other.id) && role.equals(other.role) - && Objects.equals(address, other.address); + && Objects.equals(host, other.host); } @Override public int hashCode() { - return Objects.hash(id, role, address, players, startedAt, lastSeen); + return Objects.hash(id, role, host, port, players, startedAt, lastSeen); } @Override public String toString() { - return "ServerInfo[" + id + " role=" + role + " players=" + players + "]"; + return "ServerInfo[" + id + " role=" + role + " " + host + ":" + port + " players=" + players + "]"; } } diff --git a/src/main/java/net/kewwbec/networkcore/command/network/AlertCommand.java b/src/main/java/net/kewwbec/networkcore/command/network/AlertCommand.java new file mode 100644 index 0000000..e2f6255 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/command/network/AlertCommand.java @@ -0,0 +1,42 @@ +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.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.networkcore.NetworkCorePermissions; +import net.kewwbec.networkcore.network.AlertService; + +import javax.annotation.Nonnull; +import java.awt.Color; + +public final class AlertCommand extends AbstractPlayerCommand { + + private final AlertService alerts; + private final RequiredArg messageArg = withRequiredArg("message", "Alert text", ArgTypes.GREEDY_STRING); + + public AlertCommand(@Nonnull AlertService alerts) { + super("alert", "Broadcast a message to every player on every server"); + this.alerts = alerts; + requirePermission(NetworkCorePermissions.ALERT); + } + + @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 msg = messageArg.get(ctx); + if (msg == null || msg.isBlank()) { + ctx.sendMessage(Message.raw("Usage: /alert ").color(Color.YELLOW)); + return; + } + alerts.broadcast(msg, sender.getUsername()); + ctx.sendMessage(Message.raw("Alert sent.").color(Color.GREEN)); + } +} diff --git a/src/main/java/net/kewwbec/networkcore/command/network/GlistCommand.java b/src/main/java/net/kewwbec/networkcore/command/network/GlistCommand.java new file mode 100644 index 0000000..4de76c8 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/command/network/GlistCommand.java @@ -0,0 +1,69 @@ +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.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.PlayerLocation; +import net.kewwbec.networkcore.api.PlayerPresence; +import net.kewwbec.networkcore.api.ServerInfo; +import net.kewwbec.networkcore.api.ServerRegistry; + +import javax.annotation.Nonnull; +import java.awt.Color; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class GlistCommand extends AbstractPlayerCommand { + + private final ServerRegistry registry; + private final PlayerPresence presence; + + public GlistCommand(@Nonnull ServerRegistry registry, @Nonnull PlayerPresence presence) { + super("glist", "List all online players across all servers"); + this.registry = registry; + this.presence = presence; + requirePermission(NetworkCorePermissions.GLIST); + } + + @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) { + 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)); + + Map> grouped = new LinkedHashMap<>(); + for (ServerInfo s : servers) grouped.put(s.id(), new ArrayList<>()); + for (PlayerLocation loc : presence.all()) { + grouped.computeIfAbsent(loc.serverId(), k -> new ArrayList<>()).add(loc); + } + + for (Map.Entry> e : grouped.entrySet()) { + ServerInfo s = registry.getServer(e.getKey()); + String role = (s == null) ? "?" : s.role(); + List on = e.getValue(); + if (on.isEmpty()) { + ctx.sendMessage(Message.raw("[" + role + "] " + e.getKey() + " (0): -").color(Color.GRAY)); + } else { + StringBuilder names = new StringBuilder(); + for (int i = 0; i < on.size(); i++) { + if (i > 0) names.append(", "); + names.append(on.get(i).username()); + } + ctx.sendMessage(Message.raw( + "[" + role + "] " + e.getKey() + " (" + on.size() + "): " + names + ).color(Color.WHITE)); + } + } + } +} diff --git a/src/main/java/net/kewwbec/networkcore/command/network/HubCommand.java b/src/main/java/net/kewwbec/networkcore/command/network/HubCommand.java new file mode 100644 index 0000000..fc9639a --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/command/network/HubCommand.java @@ -0,0 +1,49 @@ +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.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.internal.LobbyPicker; +import net.kewwbec.networkcore.network.RefRequestService; + +import javax.annotation.Nonnull; +import java.awt.Color; + +public final class HubCommand extends AbstractPlayerCommand { + + private final LobbyPicker picker; + private final RefRequestService refRequest; + private final String selfServerId; + + public HubCommand(@Nonnull LobbyPicker picker, @Nonnull RefRequestService refRequest, @Nonnull String selfServerId) { + super("hub", "Send yourself to a lobby"); + this.picker = picker; + this.refRequest = refRequest; + this.selfServerId = selfServerId; + requirePermission(NetworkCorePermissions.HUB); + } + + @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) { + ServerInfo target = picker.pick(selfServerId); + if (target == null) { + ctx.sendMessage(Message.raw("No lobby available right now.").color(Color.RED)); + return; + } + boolean ok = refRequest.referLocal(sender, target); + if (ok) { + ctx.sendMessage(Message.raw("Sending you to " + target.id() + "...").color(Color.GREEN)); + } else { + ctx.sendMessage(Message.raw("Couldn't send you to " + target.id() + ".").color(Color.RED)); + } + } +} diff --git a/src/main/java/net/kewwbec/networkcore/command/network/SendCommand.java b/src/main/java/net/kewwbec/networkcore/command/network/SendCommand.java new file mode 100644 index 0000000..f56f8cd --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/command/network/SendCommand.java @@ -0,0 +1,118 @@ +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.Collection; +import java.util.Comparator; +import java.util.UUID; + +/** + * /send + * + * Target server resolution: + * 1. exact server id match + * 2. otherwise treat as a role, pick the least-loaded referrable server with that role + */ +public final class SendCommand extends AbstractPlayerCommand { + + private final ServerRegistry registry; + private final PlayerPresence presence; + private final RefRequestService refRequest; + private final RequiredArg playerArg = withRequiredArg("player", "Player name or UUID", ArgTypes.STRING); + private final RequiredArg serverArg = withRequiredArg("server", "Server id or role", ArgTypes.STRING); + + public SendCommand(@Nonnull ServerRegistry registry, @Nonnull PlayerPresence presence, @Nonnull RefRequestService refRequest) { + super("send", "Move another player to a server"); + this.registry = registry; + this.presence = presence; + this.refRequest = refRequest; + requirePermission(NetworkCorePermissions.SEND); + } + + @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 playerArgVal = playerArg.get(ctx); + String serverArgVal = serverArg.get(ctx); + if (playerArgVal == null || serverArgVal == null) { + ctx.sendMessage(Message.raw("Usage: /send ").color(Color.YELLOW)); + return; + } + + UUID targetUuid = resolvePlayer(playerArgVal); + if (targetUuid == null) { + ctx.sendMessage(Message.raw("Couldn't find a player matching '" + playerArgVal + "' on the network.").color(Color.RED)); + return; + } + + ServerInfo targetServer = resolveServer(serverArgVal); + if (targetServer == null) { + ctx.sendMessage(Message.raw("No server matches id or role '" + serverArgVal + "'.").color(Color.RED)); + return; + } + if (!targetServer.isReferrable()) { + ctx.sendMessage(Message.raw("Server " + targetServer.id() + " has no public address configured.").color(Color.RED)); + return; + } + + PlayerLocation loc = presence.getLocation(targetUuid); + Universe universe = Universe.get(); + PlayerRef localRef = (universe == null) ? null : universe.getPlayer(targetUuid); + if (localRef != null) { + boolean ok = refRequest.referLocal(localRef, targetServer); + ctx.sendMessage(Message.raw( + ok ? "Sent " + localRef.getUsername() + " to " + targetServer.id() + "." + : "Couldn't refer that player locally." + ).color(ok ? Color.GREEN : Color.RED)); + return; + } + + refRequest.requestRemote(targetUuid, targetServer); + String onServer = (loc == null) ? "unknown server" : loc.serverId(); + ctx.sendMessage(Message.raw("Asked " + onServer + " to send " + targetUuid + " to " + targetServer.id() + ".").color(Color.GREEN)); + } + + @Nullable + private UUID resolvePlayer(@Nonnull String input) { + try { return UUID.fromString(input); } catch (IllegalArgumentException ignored) {} + PlayerLocation byName = presence.findByName(input); + if (byName != null) return byName.uuid(); + Universe universe = Universe.get(); + if (universe == null) return null; + for (PlayerRef p : universe.getPlayers()) { + if (p.getUsername().equalsIgnoreCase(input)) return p.getUuid(); + } + return null; + } + + @Nullable + private ServerInfo resolveServer(@Nonnull String input) { + ServerInfo exact = registry.getServer(input); + if (exact != null) return exact; + Collection byRole = 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/config/CoreConfig.java b/src/main/java/net/kewwbec/networkcore/config/CoreConfig.java index c4c77a8..f4fe45c 100644 --- a/src/main/java/net/kewwbec/networkcore/config/CoreConfig.java +++ b/src/main/java/net/kewwbec/networkcore/config/CoreConfig.java @@ -15,6 +15,25 @@ public final class CoreConfig { @Nullable public String id = null; public String role = "lobby"; + /** + * Hostname/IP other servers should use when referring (transferring) players to this one. + * Null = auto-detect at boot (system hostname). + */ + @Nullable + public String public_address = null; + /** + * Port other servers should use. Null = Hytale's DEFAULT_PORT. + */ + @Nullable + public Integer public_port = null; + /** + * Lobby role name. Used by /hub and graceful-shutdown handoff to find a lobby. + */ + public String lobby_role = "lobby"; + /** + * Fallback: if no server matches `lobby_role`, accept any role starting with this prefix. + */ + public String lobby_role_prefix = "lobby_"; } public static final class RedisSection { diff --git a/src/main/java/net/kewwbec/networkcore/internal/LobbyPicker.java b/src/main/java/net/kewwbec/networkcore/internal/LobbyPicker.java new file mode 100644 index 0000000..fc38d17 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/internal/LobbyPicker.java @@ -0,0 +1,54 @@ +package net.kewwbec.networkcore.internal; + +import net.kewwbec.networkcore.api.ServerInfo; +import net.kewwbec.networkcore.api.ServerRegistry; +import net.kewwbec.networkcore.config.CoreConfig; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Finds a lobby server to send a player to. + * + * Resolution order: + * 1. Servers with role exactly matching {@link CoreConfig.ServerSection#lobby_role}. + * 2. Servers whose role starts with {@link CoreConfig.ServerSection#lobby_role_prefix}. + * + * Among candidates, returns the one with fewest current players. Servers without a host/port + * (not referrable) are skipped. Optionally excludes the caller's own server id. + */ +public final class LobbyPicker { + + private final ServerRegistry registry; + private final CoreConfig.ServerSection cfg; + + public LobbyPicker(@Nonnull ServerRegistry registry, @Nonnull CoreConfig.ServerSection cfg) { + this.registry = registry; + this.cfg = cfg; + } + + @Nullable + public ServerInfo pick(@Nullable String excludeServerId) { + List exact = new ArrayList<>(); + List prefix = new ArrayList<>(); + for (ServerInfo s : registry.getServers()) { + if (excludeServerId != null && excludeServerId.equals(s.id())) continue; + if (!s.isReferrable()) continue; + String role = s.role(); + if (role == null) continue; + if (role.equalsIgnoreCase(cfg.lobby_role)) { + exact.add(s); + } else if (cfg.lobby_role_prefix != null && !cfg.lobby_role_prefix.isEmpty() + && role.toLowerCase().startsWith(cfg.lobby_role_prefix.toLowerCase())) { + prefix.add(s); + } + } + List chosenPool = exact.isEmpty() ? prefix : exact; + if (chosenPool.isEmpty()) return null; + chosenPool.sort(Comparator.comparingInt(ServerInfo::players)); + return chosenPool.get(0); + } +} diff --git a/src/main/java/net/kewwbec/networkcore/internal/NetworkAddress.java b/src/main/java/net/kewwbec/networkcore/internal/NetworkAddress.java new file mode 100644 index 0000000..f317d48 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/internal/NetworkAddress.java @@ -0,0 +1,51 @@ +package net.kewwbec.networkcore.internal; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.HytaleServer; +import net.kewwbec.networkcore.config.CoreConfig; + +import javax.annotation.Nonnull; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.logging.Level; + +/** + * Resolves the public host:port other servers should use to refer players here. + * Precedence: config -> auto-detected hostname / Hytale default port. + */ +public final class NetworkAddress { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final String host; + private final int port; + + public NetworkAddress(@Nonnull String host, int port) { + this.host = host; + this.port = port; + } + + @Nonnull public String host() { return host; } + public int port() { return port; } + + @Nonnull + public static NetworkAddress resolve(@Nonnull CoreConfig.ServerSection cfg) { + String host = cfg.public_address; + if (host == null || host.isBlank()) { + host = detectHostname(); + } + int port = (cfg.public_port == null) ? HytaleServer.DEFAULT_PORT : cfg.public_port; + LOGGER.at(Level.INFO).log("Public network address resolved to %s:%d", host, port); + return new NetworkAddress(host, port); + } + + @Nonnull + private static String detectHostname() { + try { + return InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + LOGGER.at(Level.WARNING).log("Could not detect hostname; falling back to 127.0.0.1. Set server.public_address in config."); + return "127.0.0.1"; + } + } +} diff --git a/src/main/java/net/kewwbec/networkcore/network/AlertPayload.java b/src/main/java/net/kewwbec/networkcore/network/AlertPayload.java new file mode 100644 index 0000000..d4605dc --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/network/AlertPayload.java @@ -0,0 +1,11 @@ +package net.kewwbec.networkcore.network; + +public final class AlertPayload { + + public static final String CHANNEL = "network:alert"; + + public String message; + public String fromUsername; + public String fromServerId; + public long ts; +} diff --git a/src/main/java/net/kewwbec/networkcore/network/AlertService.java b/src/main/java/net/kewwbec/networkcore/network/AlertService.java new file mode 100644 index 0000000..08a9b1a --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/network/AlertService.java @@ -0,0 +1,64 @@ +package net.kewwbec.networkcore.network; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import net.kewwbec.networkcore.api.MessageBus; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.awt.Color; +import java.util.logging.Level; + +/** + * Cross-server alert broadcaster. Anyone with /alert publishes; every server's listener shows the + * message to every online player. + */ +public final class AlertService { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final MessageBus bus; + private final String selfServerId; + @Nullable private MessageBus.Subscription sub; + + public AlertService(@Nonnull MessageBus bus, @Nonnull String selfServerId) { + this.bus = bus; + this.selfServerId = selfServerId; + } + + public void start() { + sub = bus.subscribe(AlertPayload.CHANNEL, AlertPayload.class, this::onAlert); + } + + public void stop() { + if (sub != null) { + try { sub.close(); } catch (RuntimeException ignored) {} + sub = null; + } + } + + public void broadcast(@Nonnull String message, @Nonnull String fromUsername) { + AlertPayload p = new AlertPayload(); + p.message = message; + p.fromUsername = fromUsername; + p.fromServerId = selfServerId; + p.ts = System.currentTimeMillis(); + bus.publish(AlertPayload.CHANNEL, p); + } + + private void onAlert(@Nonnull AlertPayload p) { + if (p.message == null) return; + Universe universe = Universe.get(); + if (universe == null) return; + String line = "[ALERT] " + p.message; + try { + for (PlayerRef ref : universe.getPlayers()) { + ref.sendMessage(Message.raw(line).color(Color.RED)); + } + } catch (RuntimeException e) { + LOGGER.at(Level.WARNING).log("Alert broadcast to players failed: %s", e.getMessage()); + } + } +} diff --git a/src/main/java/net/kewwbec/networkcore/network/RefRequestPayload.java b/src/main/java/net/kewwbec/networkcore/network/RefRequestPayload.java new file mode 100644 index 0000000..dbcb035 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/network/RefRequestPayload.java @@ -0,0 +1,11 @@ +package net.kewwbec.networkcore.network; + +public final class RefRequestPayload { + + public static final String CHANNEL = "network:ref-request"; + + public String targetUuid; + public String host; + public int port; + public String fromServerId; +} diff --git a/src/main/java/net/kewwbec/networkcore/network/RefRequestService.java b/src/main/java/net/kewwbec/networkcore/network/RefRequestService.java new file mode 100644 index 0000000..533261e --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/network/RefRequestService.java @@ -0,0 +1,95 @@ +package net.kewwbec.networkcore.network; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import net.kewwbec.networkcore.api.MessageBus; +import net.kewwbec.networkcore.api.ServerInfo; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.UUID; +import java.util.logging.Level; + +/** + * Publishes and handles cross-server "please refer this player" requests. Every server subscribes; + * the one that has the target player locally does the actual referToServer call. + * + * For local players, callers can call {@link #referLocal(PlayerRef, ServerInfo)} directly without + * involving the bus. + */ +public final class RefRequestService { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final MessageBus bus; + private final String selfServerId; + @Nullable private MessageBus.Subscription sub; + + public RefRequestService(@Nonnull MessageBus bus, @Nonnull String selfServerId) { + this.bus = bus; + this.selfServerId = selfServerId; + } + + public void start() { + sub = bus.subscribe(RefRequestPayload.CHANNEL, RefRequestPayload.class, this::onRemote); + } + + public void stop() { + if (sub != null) { + try { sub.close(); } catch (RuntimeException ignored) {} + sub = null; + } + } + + /** + * Fast path for a player connected to THIS server. Returns true if the call succeeded. + */ + public boolean referLocal(@Nonnull PlayerRef ref, @Nonnull ServerInfo target) { + if (!target.isReferrable()) { + LOGGER.at(Level.WARNING).log("Cannot refer to %s: no host/port", target.id()); + return false; + } + try { + ref.referToServer(target.host(), target.port()); + return true; + } catch (RuntimeException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("referToServer failed for %s -> %s", ref.getUuid(), target.id()); + return false; + } + } + + /** + * Asks every server on the bus to refer the given player. The one that has them locally acts. + * Returns immediately; the actual refer happens asynchronously on the holding server. + */ + public void requestRemote(@Nonnull UUID targetUuid, @Nonnull ServerInfo target) { + if (!target.isReferrable()) { + LOGGER.at(Level.WARNING).log("Cannot request refer: target %s is not referrable", target.id()); + return; + } + RefRequestPayload p = new RefRequestPayload(); + p.targetUuid = targetUuid.toString(); + p.host = target.host(); + p.port = target.port(); + p.fromServerId = selfServerId; + bus.publish(RefRequestPayload.CHANNEL, p); + } + + private void onRemote(@Nonnull RefRequestPayload p) { + if (p.targetUuid == null || p.host == null) return; + UUID uuid; + try { uuid = UUID.fromString(p.targetUuid); } catch (IllegalArgumentException e) { return; } + Universe universe = Universe.get(); + if (universe == null) return; + PlayerRef ref = universe.getPlayer(uuid); + if (ref == null) return; + try { + ref.referToServer(p.host, p.port); + LOGGER.at(Level.INFO).log("Referred %s to %s:%d (request from %s)", + ref.getUsername(), p.host, p.port, p.fromServerId); + } catch (RuntimeException e) { + ((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("Remote refer of %s failed", uuid); + } + } +} diff --git a/src/main/java/net/kewwbec/networkcore/network/ShutdownHandoff.java b/src/main/java/net/kewwbec/networkcore/network/ShutdownHandoff.java new file mode 100644 index 0000000..836177a --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/network/ShutdownHandoff.java @@ -0,0 +1,63 @@ +package net.kewwbec.networkcore.network; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import net.kewwbec.networkcore.api.ServerInfo; +import net.kewwbec.networkcore.internal.LobbyPicker; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; + +/** + * On graceful shutdown, walk our local players and send each to a chosen lobby before the server + * dies. Best-effort: if no lobby is available, players get the normal disconnect. + * + * For ungraceful crashes (kill -9, OOM, network partition) this code never runs. Players hit the + * normal disconnect screen and reconnect manually. + */ +public final class ShutdownHandoff { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final LobbyPicker picker; + private final String selfServerId; + + public ShutdownHandoff(@Nonnull LobbyPicker picker, @Nonnull String selfServerId) { + this.picker = picker; + this.selfServerId = selfServerId; + } + + /** + * Walks Universe.getPlayers() and refers each to a lobby. Returns the count handed off. + */ + public int handoffAll() { + Universe universe = Universe.get(); + if (universe == null) return 0; + + ServerInfo target = picker.pick(selfServerId); + if (target == null) { + LOGGER.at(Level.WARNING).log("Shutdown handoff: no lobby available, players will disconnect"); + return 0; + } + if (!target.isReferrable()) { + LOGGER.at(Level.WARNING).log("Shutdown handoff: target %s has no host/port", target.id()); + return 0; + } + + List snapshot = new ArrayList<>(universe.getPlayers()); + int handled = 0; + for (PlayerRef ref : snapshot) { + try { + ref.referToServer(target.host(), target.port()); + handled++; + } catch (RuntimeException e) { + LOGGER.at(Level.WARNING).log("Refer failed for %s: %s", ref.getUsername(), e.getMessage()); + } + } + LOGGER.at(Level.INFO).log("Shutdown handoff: referred %d player(s) to %s", handled, target.id()); + return handled; + } +} diff --git a/src/main/java/net/kewwbec/networkcore/presence/PlayerPresenceService.java b/src/main/java/net/kewwbec/networkcore/presence/PlayerPresenceService.java new file mode 100644 index 0000000..4af5b4c --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/presence/PlayerPresenceService.java @@ -0,0 +1,185 @@ +package net.kewwbec.networkcore.presence; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.HytaleServer; +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.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; +import net.kewwbec.networkcore.api.MessageBus; +import net.kewwbec.networkcore.api.PlayerLocation; +import net.kewwbec.networkcore.api.PlayerPresence; +import net.kewwbec.networkcore.api.events.ServerLeftNetworkEvent; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; + +/** + * Tracks every online player across the network. Maintained by: + * - Local PlayerConnect/Disconnect events (we publish presence changes). + * - Other servers' presence change broadcasts. + * - ServerLeftNetworkEvent (purges players known to be on that server). + * + * On boot, we re-publish our local roster so other servers refresh after our restart. + */ +public final class PlayerPresenceService implements PlayerPresence { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final MessageBus bus; + private final String selfServerId; + + private final Map byUuid = new ConcurrentHashMap<>(); + private final Map> byServer = new ConcurrentHashMap<>(); + + @Nullable private MessageBus.Subscription sub; + + public PlayerPresenceService(@Nonnull MessageBus bus, @Nonnull String selfServerId) { + this.bus = bus; + this.selfServerId = selfServerId; + } + + public void start() { + sub = bus.subscribe(PresenceChangePayload.CHANNEL, PresenceChangePayload.class, this::onRemote); + // Republish our current roster so other servers refresh us + Universe universe = Universe.get(); + if (universe != null) { + for (PlayerRef ref : universe.getPlayers()) { + publish(PresenceChangePayload.KIND_SYNC, ref.getUuid(), ref.getUsername()); + applyLocal(ref.getUuid(), ref.getUsername(), selfServerId, System.currentTimeMillis()); + } + } + } + + public void stop() { + if (sub != null) { + try { sub.close(); } catch (RuntimeException ignored) {} + sub = null; + } + // Broadcast leaves for everyone on this server so other servers' caches don't go stale. + for (UUID uuid : new ArrayList<>(byServer.getOrDefault(selfServerId, Set.of()))) { + PlayerLocation loc = byUuid.get(uuid); + String name = (loc == null) ? "" : loc.username(); + publish(PresenceChangePayload.KIND_LEAVE, uuid, name); + } + byUuid.clear(); + byServer.clear(); + } + + public void onConnect(@Nonnull PlayerConnectEvent event) { + PlayerRef ref = event.getPlayerRef(); + if (ref == null) return; + applyLocal(ref.getUuid(), ref.getUsername(), selfServerId, System.currentTimeMillis()); + publish(PresenceChangePayload.KIND_JOIN, ref.getUuid(), ref.getUsername()); + } + + public void onDisconnect(@Nonnull PlayerDisconnectEvent event) { + PlayerRef ref = event.getPlayerRef(); + if (ref == null) return; + removeLocal(ref.getUuid()); + publish(PresenceChangePayload.KIND_LEAVE, ref.getUuid(), ref.getUsername()); + } + + public void onServerLeft(@Nonnull ServerLeftNetworkEvent event) { + String gone = event.getServer().id(); + Set hadOnIt = byServer.remove(gone); + if (hadOnIt != null) { + for (UUID uuid : hadOnIt) { + byUuid.remove(uuid); + } + } + } + + private void onRemote(@Nonnull PresenceChangePayload p) { + if (p.uuid == null || p.kind == null || p.serverId == null) return; + if (selfServerId.equals(p.serverId)) return; + UUID uuid; + try { uuid = UUID.fromString(p.uuid); } catch (IllegalArgumentException e) { return; } + switch (p.kind) { + case PresenceChangePayload.KIND_JOIN, PresenceChangePayload.KIND_SYNC -> + applyLocal(uuid, p.username == null ? "" : p.username, p.serverId, p.ts); + case PresenceChangePayload.KIND_LEAVE -> removeLocal(uuid); + default -> {} + } + } + + private void publish(@Nonnull String kind, @Nonnull UUID uuid, @Nonnull String name) { + try { + PresenceChangePayload p = new PresenceChangePayload(); + p.kind = kind; + p.uuid = uuid.toString(); + p.username = name; + p.serverId = selfServerId; + p.ts = System.currentTimeMillis(); + bus.publish(PresenceChangePayload.CHANNEL, p); + } catch (RuntimeException e) { + LOGGER.at(Level.WARNING).log("Presence publish failed: %s", e.getMessage()); + } + } + + private void applyLocal(@Nonnull UUID uuid, @Nonnull String username, @Nonnull String serverId, long ts) { + PlayerLocation prev = byUuid.put(uuid, new PlayerLocation(uuid, username, serverId, ts)); + if (prev != null && !prev.serverId().equals(serverId)) { + Set oldSet = byServer.get(prev.serverId()); + if (oldSet != null) oldSet.remove(uuid); + } + byServer.computeIfAbsent(serverId, k -> ConcurrentHashMap.newKeySet()).add(uuid); + } + + private void removeLocal(@Nonnull UUID uuid) { + PlayerLocation prev = byUuid.remove(uuid); + if (prev != null) { + Set s = byServer.get(prev.serverId()); + if (s != null) s.remove(uuid); + } + } + + @Override + @Nullable + public PlayerLocation getLocation(@Nonnull UUID uuid) { + return byUuid.get(uuid); + } + + @Override + @Nullable + public PlayerLocation findByName(@Nonnull String username) { + for (PlayerLocation l : byUuid.values()) { + if (l.username().equalsIgnoreCase(username)) return l; + } + return null; + } + + @Override + @Nonnull + public Collection all() { + return Collections.unmodifiableCollection(new ArrayList<>(byUuid.values())); + } + + @Override + @Nonnull + public Collection onServer(@Nonnull String serverId) { + Set ids = byServer.get(serverId); + if (ids == null) return List.of(); + List out = new ArrayList<>(); + for (UUID u : new HashSet<>(ids)) { + PlayerLocation l = byUuid.get(u); + if (l != null) out.add(l); + } + return Collections.unmodifiableCollection(out); + } + + @Override + public int onlineCount() { + return byUuid.size(); + } +} diff --git a/src/main/java/net/kewwbec/networkcore/presence/PresenceChangePayload.java b/src/main/java/net/kewwbec/networkcore/presence/PresenceChangePayload.java new file mode 100644 index 0000000..86ecf7d --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/presence/PresenceChangePayload.java @@ -0,0 +1,15 @@ +package net.kewwbec.networkcore.presence; + +public final class PresenceChangePayload { + + public static final String CHANNEL = "network:presence"; + public static final String KIND_JOIN = "join"; + public static final String KIND_LEAVE = "leave"; + public static final String KIND_SYNC = "sync"; + + public String kind; + public String uuid; + public String username; + public String serverId; + public long ts; +} diff --git a/src/main/java/net/kewwbec/networkcore/registry/PresencePayload.java b/src/main/java/net/kewwbec/networkcore/registry/PresencePayload.java index 69842a2..a7e611b 100644 --- a/src/main/java/net/kewwbec/networkcore/registry/PresencePayload.java +++ b/src/main/java/net/kewwbec/networkcore/registry/PresencePayload.java @@ -9,6 +9,8 @@ public final class PresencePayload { public String kind; public String id; public String role; + public String host; + public int port; public int players; public long startedAt; public long ts; diff --git a/src/main/java/net/kewwbec/networkcore/registry/RedisServerRegistry.java b/src/main/java/net/kewwbec/networkcore/registry/RedisServerRegistry.java index 217d299..9dc0b79 100644 --- a/src/main/java/net/kewwbec/networkcore/registry/RedisServerRegistry.java +++ b/src/main/java/net/kewwbec/networkcore/registry/RedisServerRegistry.java @@ -13,6 +13,7 @@ import net.kewwbec.networkcore.api.events.ServerJoinedNetworkEvent; import net.kewwbec.networkcore.api.events.ServerLeftNetworkEvent; import net.kewwbec.networkcore.bus.RedisMessageBus; import net.kewwbec.networkcore.config.CoreConfig; +import net.kewwbec.networkcore.internal.NetworkAddress; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -39,6 +40,7 @@ public final class RedisServerRegistry implements ServerRegistry { private final String keyPrefix; private final String id; private final String role; + private final NetworkAddress publicAddress; private final long startedAt; private final Map cache = new ConcurrentHashMap<>(); @@ -50,11 +52,13 @@ public final class RedisServerRegistry implements ServerRegistry { public RedisServerRegistry(@Nonnull RedisMessageBus bus, @Nonnull CoreConfig.RedisSection redis, @Nonnull String id, - @Nonnull String role) { + @Nonnull String role, + @Nonnull NetworkAddress publicAddress) { this.bus = bus; this.keyPrefix = redis.key_prefix + "servers:"; this.id = id; this.role = role; + this.publicAddress = publicAddress; this.startedAt = System.currentTimeMillis(); this.eventBus = HytaleServer.get().getEventBus(); } @@ -100,6 +104,8 @@ public final class RedisServerRegistry implements ServerRegistry { p.kind = kind; p.id = id; p.role = role; + p.host = publicAddress.host(); + p.port = publicAddress.port(); p.players = currentPlayerCount(); p.startedAt = startedAt; p.ts = System.currentTimeMillis(); @@ -111,7 +117,15 @@ public final class RedisServerRegistry implements ServerRegistry { if (id.equals(p.id)) return; if (PresencePayload.KIND_HELLO.equals(p.kind)) { - ServerInfo info = new ServerInfo(p.id, p.role != null ? p.role : "unknown", null, p.players, p.startedAt, p.ts); + ServerInfo info = new ServerInfo( + p.id, + p.role != null ? p.role : "unknown", + p.host, + p.port, + p.players, + p.startedAt, + p.ts + ); ServerInfo previous = cache.put(p.id, info); if (previous == null) { try { @@ -136,6 +150,8 @@ public final class RedisServerRegistry implements ServerRegistry { Map fields = new HashMap<>(); fields.put("id", id); fields.put("role", role); + fields.put("host", publicAddress.host()); + fields.put("port", Integer.toString(publicAddress.port())); fields.put("players", Integer.toString(currentPlayerCount())); fields.put("started_at", Long.toString(startedAt)); fields.put("last_seen", Long.toString(System.currentTimeMillis())); @@ -158,7 +174,8 @@ public final class RedisServerRegistry implements ServerRegistry { ServerInfo info = new ServerInfo( otherId, hash.getOrDefault("role", "unknown"), - hash.get("address"), + hash.get("host"), + parseInt(hash.get("port"), 0), parseInt(hash.get("players"), 0), parseLong(hash.get("started_at"), 0L), parseLong(hash.get("last_seen"), 0L) @@ -189,7 +206,7 @@ public final class RedisServerRegistry implements ServerRegistry { } } for (String l : left) { - ServerInfo info = new ServerInfo(l, "unknown", null, 0, 0L, 0L); + ServerInfo info = new ServerInfo(l, "unknown", null, 0, 0, 0L, 0L); try { eventBus.dispatchFor(ServerLeftNetworkEvent.class).dispatch(new ServerLeftNetworkEvent(info)); } catch (RuntimeException ignored) { @@ -202,7 +219,8 @@ public final class RedisServerRegistry implements ServerRegistry { public ServerInfo getSelf() { ServerInfo cached = cache.get(id); if (cached != null) return cached; - return new ServerInfo(id, role, null, currentPlayerCount(), startedAt, System.currentTimeMillis()); + return new ServerInfo(id, role, publicAddress.host(), publicAddress.port(), + currentPlayerCount(), startedAt, System.currentTimeMillis()); } @Override