extra services
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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()`.
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user