extra services

This commit is contained in:
2026-05-26 23:14:19 -04:00
parent 1f5aa7b79c
commit 2c644f02f0
25 changed files with 1113 additions and 16 deletions
+9 -1
View File
@@ -10,7 +10,11 @@
{ {
"server": { "server": {
"id": null, "id": null,
"role": "lobby" "role": "lobby",
"public_address": null,
"public_port": null,
"lobby_role": "lobby",
"lobby_role_prefix": "lobby_"
}, },
"redis": { "redis": {
"url": null, "url": null,
@@ -59,6 +63,10 @@
|---|---|---| |---|---|---|
| `id` | `null` | Optional fixed server id. If null, auto-generated as `<role>-<8 hex chars>` (e.g. `lobby-3f2a91bd`) on every boot. Set this when you want a stable id across restarts. | | `id` | `null` | Optional fixed server id. If null, auto-generated as `<role>-<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. | | `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 ### redis
+6
View File
@@ -13,6 +13,12 @@ Every subcommand is gated by a permission node, checked via Hytale's `Permission
| `/networkcore status` | `networkcore.status` | | `/networkcore status` | `networkcore.status` |
| `/networkcore servers` | `networkcore.servers` | | `/networkcore servers` | `networkcore.servers` |
| `/networkcore publish` | `networkcore.publish` | | `/networkcore publish` | `networkcore.publish` |
| `/alert <message>` | `networkcore.alert` |
| `/glist` | `networkcore.glist` |
| `/hub` | `networkcore.hub` |
| `/send <player> <id\|role>` | `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. 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.
+85
View File
@@ -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 <message>` | `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 <player> <serverIdOrRole>` | `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 <host>:<port>`. 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()`.
+1
View File
@@ -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 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 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) 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 ## Quick reference
@@ -1,23 +1,38 @@
package net.kewwbec.networkcore; package net.kewwbec.networkcore;
import com.hypixel.hytale.logger.HytaleLogger; 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.JavaPlugin;
import com.hypixel.hytale.server.core.plugin.JavaPluginInit; import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
import net.kewwbec.networkcore.api.DatabaseService; import net.kewwbec.networkcore.api.DatabaseService;
import net.kewwbec.networkcore.api.MessageBus; import net.kewwbec.networkcore.api.MessageBus;
import net.kewwbec.networkcore.api.MetricsService; import net.kewwbec.networkcore.api.MetricsService;
import net.kewwbec.networkcore.api.PlayerPresence;
import net.kewwbec.networkcore.api.RpcClient; import net.kewwbec.networkcore.api.RpcClient;
import net.kewwbec.networkcore.api.ServerRegistry; import net.kewwbec.networkcore.api.ServerRegistry;
import net.kewwbec.networkcore.api.events.ServerLeftNetworkEvent;
import net.kewwbec.networkcore.bus.RedisMessageBus; import net.kewwbec.networkcore.bus.RedisMessageBus;
import net.kewwbec.networkcore.command.NetworkCoreCommand; 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.ConfigLoader;
import net.kewwbec.networkcore.config.CoreConfig; import net.kewwbec.networkcore.config.CoreConfig;
import net.kewwbec.networkcore.db.MySqlDatabaseService; 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.ServerIdGenerator;
import net.kewwbec.networkcore.internal.ServiceRegistry; import net.kewwbec.networkcore.internal.ServiceRegistry;
import net.kewwbec.networkcore.metrics.BuiltInMetrics; import net.kewwbec.networkcore.metrics.BuiltInMetrics;
import net.kewwbec.networkcore.metrics.MetricsServiceImpl; import net.kewwbec.networkcore.metrics.MetricsServiceImpl;
import net.kewwbec.networkcore.metrics.PrometheusExporter; import net.kewwbec.networkcore.metrics.PrometheusExporter;
import net.kewwbec.networkcore.network.AlertService;
import net.kewwbec.networkcore.network.RefRequestService;
import net.kewwbec.networkcore.network.ShutdownHandoff;
import net.kewwbec.networkcore.presence.PlayerPresenceService;
import net.kewwbec.networkcore.registry.RedisServerRegistry; import net.kewwbec.networkcore.registry.RedisServerRegistry;
import net.kewwbec.networkcore.rpc.RpcClientImpl; import net.kewwbec.networkcore.rpc.RpcClientImpl;
@@ -35,6 +50,7 @@ public final class NetworkCore extends JavaPlugin {
private CoreConfig config; private CoreConfig config;
private String serverId; private String serverId;
private NetworkAddress publicAddress;
@Nullable private MetricsServiceImpl metricsImpl; @Nullable private MetricsServiceImpl metricsImpl;
@Nullable private PrometheusExporter exporter; @Nullable private PrometheusExporter exporter;
@@ -42,6 +58,10 @@ public final class NetworkCore extends JavaPlugin {
@Nullable private RedisServerRegistry serverRegistry; @Nullable private RedisServerRegistry serverRegistry;
@Nullable private RpcClientImpl rpcClient; @Nullable private RpcClientImpl rpcClient;
@Nullable private MySqlDatabaseService database; @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) { public NetworkCore(@Nonnull JavaPluginInit init) {
super(init); super(init);
@@ -66,6 +86,7 @@ public final class NetworkCore extends JavaPlugin {
this.serverId = (config.server.id == null || config.server.id.isBlank()) this.serverId = (config.server.id == null || config.server.id.isBlank())
? ServerIdGenerator.generate(config.server.role) ? ServerIdGenerator.generate(config.server.role)
: config.server.id; : config.server.id;
this.publicAddress = NetworkAddress.resolve(config.server);
if (config.metrics.enabled) { if (config.metrics.enabled) {
this.metricsImpl = new MetricsServiceImpl(); this.metricsImpl = new MetricsServiceImpl();
@@ -97,7 +118,7 @@ public final class NetworkCore extends JavaPlugin {
return; 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(); serverRegistry.start();
services.register(ServerRegistry.class, serverRegistry); 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"); 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"); LOGGER.at(Level.INFO).log("NetworkCore started");
} }
@Override @Override
protected void shutdown() { 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) { if (rpcClient != null) {
try { rpcClient.close(); } catch (RuntimeException ignored) {} try { rpcClient.close(); } catch (RuntimeException ignored) {}
rpcClient = null; rpcClient = null;
@@ -169,6 +233,8 @@ public final class NetworkCore extends JavaPlugin {
public RpcClient getRpc() { return services.get(RpcClient.class); } public RpcClient getRpc() { return services.get(RpcClient.class); }
@Nullable @Nullable
public DatabaseService getDatabase() { return services.get(DatabaseService.class); } public DatabaseService getDatabase() { return services.get(DatabaseService.class); }
@Nullable
public PlayerPresence getPlayerPresence() { return services.get(PlayerPresence.class); }
@Nonnull @Nonnull
public CoreConfig getConfigSnapshot() { public CoreConfig getConfigSnapshot() {
@@ -7,6 +7,11 @@ public final class NetworkCorePermissions {
public static final String SERVERS = ROOT + ".servers"; public static final String SERVERS = ROOT + ".servers";
public static final String PUBLISH = ROOT + ".publish"; 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() { private NetworkCorePermissions() {
} }
} }
@@ -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
) {
}
@@ -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<PlayerLocation> all();
/**
* Snapshot of players known to be on the given server id.
*/
@Nonnull
Collection<PlayerLocation> onServer(@Nonnull String serverId);
/**
* Best-effort total count.
*/
int onlineCount();
}
@@ -8,21 +8,23 @@ public final class ServerInfo {
private final String id; private final String id;
private final String role; private final String role;
@Nullable @Nullable private final String host;
private final String address; private final int port;
private final int players; private final int players;
private final long startedAt; private final long startedAt;
private final long lastSeen; private final long lastSeen;
public ServerInfo(@Nonnull String id, public ServerInfo(@Nonnull String id,
@Nonnull String role, @Nonnull String role,
@Nullable String address, @Nullable String host,
int port,
int players, int players,
long startedAt, long startedAt,
long lastSeen) { long lastSeen) {
this.id = id; this.id = id;
this.role = role; this.role = role;
this.address = address; this.host = host;
this.port = port;
this.players = players; this.players = players;
this.startedAt = startedAt; this.startedAt = startedAt;
this.lastSeen = lastSeen; this.lastSeen = lastSeen;
@@ -30,30 +32,37 @@ public final class ServerInfo {
@Nonnull public String id() { return id; } @Nonnull public String id() { return id; }
@Nonnull public String role() { return role; } @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 int players() { return players; }
public long startedAt() { return startedAt; } public long startedAt() { return startedAt; }
public long lastSeen() { return lastSeen; } 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 @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) return true; if (this == o) return true;
if (!(o instanceof ServerInfo other)) return false; if (!(o instanceof ServerInfo other)) return false;
return players == other.players return port == other.port
&& players == other.players
&& startedAt == other.startedAt && startedAt == other.startedAt
&& lastSeen == other.lastSeen && lastSeen == other.lastSeen
&& id.equals(other.id) && id.equals(other.id)
&& role.equals(other.role) && role.equals(other.role)
&& Objects.equals(address, other.address); && Objects.equals(host, other.host);
} }
@Override @Override
public int hashCode() { public int hashCode() {
return Objects.hash(id, role, address, players, startedAt, lastSeen); return Objects.hash(id, role, host, port, players, startedAt, lastSeen);
} }
@Override @Override
public String toString() { public String toString() {
return "ServerInfo[" + id + " role=" + role + " players=" + players + "]"; return "ServerInfo[" + id + " role=" + role + " " + host + ":" + port + " players=" + players + "]";
} }
} }
@@ -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<String> 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<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef sender, @Nonnull World world) {
String msg = messageArg.get(ctx);
if (msg == null || msg.isBlank()) {
ctx.sendMessage(Message.raw("Usage: /alert <message>").color(Color.YELLOW));
return;
}
alerts.broadcast(msg, sender.getUsername());
ctx.sendMessage(Message.raw("Alert sent.").color(Color.GREEN));
}
}
@@ -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<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));
Map<String, List<PlayerLocation>> 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<String, List<PlayerLocation>> e : grouped.entrySet()) {
ServerInfo s = registry.getServer(e.getKey());
String role = (s == null) ? "?" : s.role();
List<PlayerLocation> 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));
}
}
}
}
@@ -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<EntityStore> store, @Nonnull Ref<EntityStore> 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));
}
}
}
@@ -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 <player> <serverIdOrRole>
*
* 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<String> playerArg = withRequiredArg("player", "Player name or UUID", ArgTypes.STRING);
private final RequiredArg<String> 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<EntityStore> store, @Nonnull Ref<EntityStore> 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 <player> <serverIdOrRole>").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<ServerInfo> byRole = registry.getServersByRole(input);
return byRole.stream()
.filter(ServerInfo::isReferrable)
.min(Comparator.comparingInt(ServerInfo::players))
.orElse(null);
}
}
@@ -15,6 +15,25 @@ public final class CoreConfig {
@Nullable @Nullable
public String id = null; public String id = null;
public String role = "lobby"; 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 { public static final class RedisSection {
@@ -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<ServerInfo> exact = new ArrayList<>();
List<ServerInfo> 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<ServerInfo> chosenPool = exact.isEmpty() ? prefix : exact;
if (chosenPool.isEmpty()) return null;
chosenPool.sort(Comparator.comparingInt(ServerInfo::players));
return chosenPool.get(0);
}
}
@@ -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";
}
}
}
@@ -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;
}
@@ -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());
}
}
}
@@ -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;
}
@@ -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);
}
}
}
@@ -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<PlayerRef> 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;
}
}
@@ -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<UUID, PlayerLocation> byUuid = new ConcurrentHashMap<>();
private final Map<String, Set<UUID>> 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<UUID> 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<UUID> 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<UUID> 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<PlayerLocation> all() {
return Collections.unmodifiableCollection(new ArrayList<>(byUuid.values()));
}
@Override
@Nonnull
public Collection<PlayerLocation> onServer(@Nonnull String serverId) {
Set<UUID> ids = byServer.get(serverId);
if (ids == null) return List.of();
List<PlayerLocation> 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();
}
}
@@ -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;
}
@@ -9,6 +9,8 @@ public final class PresencePayload {
public String kind; public String kind;
public String id; public String id;
public String role; public String role;
public String host;
public int port;
public int players; public int players;
public long startedAt; public long startedAt;
public long ts; public long ts;
@@ -13,6 +13,7 @@ import net.kewwbec.networkcore.api.events.ServerJoinedNetworkEvent;
import net.kewwbec.networkcore.api.events.ServerLeftNetworkEvent; import net.kewwbec.networkcore.api.events.ServerLeftNetworkEvent;
import net.kewwbec.networkcore.bus.RedisMessageBus; import net.kewwbec.networkcore.bus.RedisMessageBus;
import net.kewwbec.networkcore.config.CoreConfig; import net.kewwbec.networkcore.config.CoreConfig;
import net.kewwbec.networkcore.internal.NetworkAddress;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@@ -39,6 +40,7 @@ public final class RedisServerRegistry implements ServerRegistry {
private final String keyPrefix; private final String keyPrefix;
private final String id; private final String id;
private final String role; private final String role;
private final NetworkAddress publicAddress;
private final long startedAt; private final long startedAt;
private final Map<String, ServerInfo> cache = new ConcurrentHashMap<>(); private final Map<String, ServerInfo> cache = new ConcurrentHashMap<>();
@@ -50,11 +52,13 @@ public final class RedisServerRegistry implements ServerRegistry {
public RedisServerRegistry(@Nonnull RedisMessageBus bus, public RedisServerRegistry(@Nonnull RedisMessageBus bus,
@Nonnull CoreConfig.RedisSection redis, @Nonnull CoreConfig.RedisSection redis,
@Nonnull String id, @Nonnull String id,
@Nonnull String role) { @Nonnull String role,
@Nonnull NetworkAddress publicAddress) {
this.bus = bus; this.bus = bus;
this.keyPrefix = redis.key_prefix + "servers:"; this.keyPrefix = redis.key_prefix + "servers:";
this.id = id; this.id = id;
this.role = role; this.role = role;
this.publicAddress = publicAddress;
this.startedAt = System.currentTimeMillis(); this.startedAt = System.currentTimeMillis();
this.eventBus = HytaleServer.get().getEventBus(); this.eventBus = HytaleServer.get().getEventBus();
} }
@@ -100,6 +104,8 @@ public final class RedisServerRegistry implements ServerRegistry {
p.kind = kind; p.kind = kind;
p.id = id; p.id = id;
p.role = role; p.role = role;
p.host = publicAddress.host();
p.port = publicAddress.port();
p.players = currentPlayerCount(); p.players = currentPlayerCount();
p.startedAt = startedAt; p.startedAt = startedAt;
p.ts = System.currentTimeMillis(); p.ts = System.currentTimeMillis();
@@ -111,7 +117,15 @@ public final class RedisServerRegistry implements ServerRegistry {
if (id.equals(p.id)) return; if (id.equals(p.id)) return;
if (PresencePayload.KIND_HELLO.equals(p.kind)) { 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); ServerInfo previous = cache.put(p.id, info);
if (previous == null) { if (previous == null) {
try { try {
@@ -136,6 +150,8 @@ public final class RedisServerRegistry implements ServerRegistry {
Map<String, String> fields = new HashMap<>(); Map<String, String> fields = new HashMap<>();
fields.put("id", id); fields.put("id", id);
fields.put("role", role); fields.put("role", role);
fields.put("host", publicAddress.host());
fields.put("port", Integer.toString(publicAddress.port()));
fields.put("players", Integer.toString(currentPlayerCount())); fields.put("players", Integer.toString(currentPlayerCount()));
fields.put("started_at", Long.toString(startedAt)); fields.put("started_at", Long.toString(startedAt));
fields.put("last_seen", Long.toString(System.currentTimeMillis())); fields.put("last_seen", Long.toString(System.currentTimeMillis()));
@@ -158,7 +174,8 @@ public final class RedisServerRegistry implements ServerRegistry {
ServerInfo info = new ServerInfo( ServerInfo info = new ServerInfo(
otherId, otherId,
hash.getOrDefault("role", "unknown"), hash.getOrDefault("role", "unknown"),
hash.get("address"), hash.get("host"),
parseInt(hash.get("port"), 0),
parseInt(hash.get("players"), 0), parseInt(hash.get("players"), 0),
parseLong(hash.get("started_at"), 0L), parseLong(hash.get("started_at"), 0L),
parseLong(hash.get("last_seen"), 0L) parseLong(hash.get("last_seen"), 0L)
@@ -189,7 +206,7 @@ public final class RedisServerRegistry implements ServerRegistry {
} }
} }
for (String l : left) { 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 { try {
eventBus.dispatchFor(ServerLeftNetworkEvent.class).dispatch(new ServerLeftNetworkEvent(info)); eventBus.dispatchFor(ServerLeftNetworkEvent.class).dispatch(new ServerLeftNetworkEvent(info));
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
@@ -202,7 +219,8 @@ public final class RedisServerRegistry implements ServerRegistry {
public ServerInfo getSelf() { public ServerInfo getSelf() {
ServerInfo cached = cache.get(id); ServerInfo cached = cache.get(id);
if (cached != null) return cached; 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 @Override