diff --git a/api/src/main/java/app/simplecloud/api/CloudApi.java b/api/src/main/java/app/simplecloud/api/CloudApi.java index d968a71..ff811a9 100644 --- a/api/src/main/java/app/simplecloud/api/CloudApi.java +++ b/api/src/main/java/app/simplecloud/api/CloudApi.java @@ -38,7 +38,7 @@ * }); * } */ -public interface CloudApi { +public interface CloudApi extends AutoCloseable { /** * Creates a CloudAPI instance with default options. @@ -143,4 +143,8 @@ static CloudApi create(CloudApiOptions options) { */ QueryCache cache(); + @Override + default void close() { + } + } diff --git a/api/src/main/java/app/simplecloud/api/internal/CloudApiImpl.java b/api/src/main/java/app/simplecloud/api/internal/CloudApiImpl.java index 64f35bd..5e467b8 100644 --- a/api/src/main/java/app/simplecloud/api/internal/CloudApiImpl.java +++ b/api/src/main/java/app/simplecloud/api/internal/CloudApiImpl.java @@ -21,6 +21,7 @@ import io.nats.client.Connection; import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; public class CloudApiImpl implements CloudApi { @@ -35,6 +36,7 @@ public class CloudApiImpl implements CloudApi { private final PersistentServerApi persistentServerApi; private final EventApi eventApi; private final PlayerApi playerApi; + private final AtomicBoolean closed = new AtomicBoolean(false); public CloudApiImpl(CloudApiOptions options) { this.options = options; @@ -73,6 +75,7 @@ public CloudApiImpl(CloudApiOptions options) { } else { this.cacheEventListener = null; } + } @Override @@ -120,4 +123,19 @@ public QueryCache cache() { return queryCache; } + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + + if (cacheEventListener != null) { + cacheEventListener.shutdown(); + } + if (queryCache instanceof QueryCacheImpl queryCacheImpl) { + queryCacheImpl.shutdown(); + } + natsConnectionManager.shutdown(); + } + } diff --git a/api/src/main/java/app/simplecloud/api/internal/integration/player/PlayerIntegration.java b/api/src/main/java/app/simplecloud/api/internal/integration/player/PlayerIntegration.java index c87f892..3c6b2c0 100644 --- a/api/src/main/java/app/simplecloud/api/internal/integration/player/PlayerIntegration.java +++ b/api/src/main/java/app/simplecloud/api/internal/integration/player/PlayerIntegration.java @@ -1,6 +1,5 @@ package app.simplecloud.api.internal.integration.player; -import app.simplecloud.api.CloudApi; import app.simplecloud.api.internal.CloudApiImpl; import app.simplecloud.api.player.CloudPlayer; import build.buf.gen.simplecloud.player.v2.*; @@ -29,12 +28,8 @@ public class PlayerIntegration { private BiFunction> kickHandler; private BiFunction> connectHandler; - public PlayerIntegration(CloudApi cloudApi) { - if (!(cloudApi instanceof CloudApiImpl)) { - throw new IllegalArgumentException("CloudApi must be an instance of CloudApiImpl"); - } - CloudApiImpl impl = (CloudApiImpl) cloudApi; - this.natsConnection = impl.getNatsConnection(); + public PlayerIntegration(CloudApiImpl cloudApi) { + this.natsConnection = cloudApi.getNatsConnection(); this.networkId = cloudApi.getNetworkId(); } diff --git a/api/src/main/java/app/simplecloud/api/internal/integration/presence/ProxyPresenceResponder.java b/api/src/main/java/app/simplecloud/api/internal/integration/presence/ProxyPresenceResponder.java new file mode 100644 index 0000000..aab06a5 --- /dev/null +++ b/api/src/main/java/app/simplecloud/api/internal/integration/presence/ProxyPresenceResponder.java @@ -0,0 +1,149 @@ +package app.simplecloud.api.internal.integration.presence; + +import app.simplecloud.api.presence.ProxyPresencePlayer; +import app.simplecloud.api.presence.ProxyPresencePlayerProvider; +import build.buf.gen.simplecloud.controller.v2.PresenceCompareRequest; +import build.buf.gen.simplecloud.controller.v2.ProxyPresenceCompareResponse; +import io.nats.client.Connection; +import io.nats.client.Dispatcher; +import io.nats.client.Message; + +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Responds to controller presence-compare requests for a single proxy. + */ +public final class ProxyPresenceResponder { + + private static final Logger LOGGER = Logger.getLogger(ProxyPresenceResponder.class.getName()); + private static final int FNV_32A_OFFSET_BASIS = 0x811c9dc5; + private static final int FNV_32A_PRIME = 0x01000193; + + private final Connection natsConnection; + private final String serverId; + private final String subject; + private final AtomicBoolean running = new AtomicBoolean(false); + private volatile ProxyPresencePlayerProvider playerProvider; + + private Dispatcher dispatcher; + + public ProxyPresenceResponder( + Connection natsConnection, + String networkId, + String serverId, + ProxyPresencePlayerProvider playerProvider + ) { + this.natsConnection = Objects.requireNonNull(natsConnection, "natsConnection"); + this.serverId = serverId == null ? "" : serverId; + this.subject = Objects.requireNonNull(networkId, "networkId") + ".server." + this.serverId + ".presence.compare"; + this.playerProvider = playerProvider; + } + + public ProxyPresenceResponder( + Connection natsConnection, + String networkId, + String serverId + ) { + this(natsConnection, networkId, serverId, null); + } + + public void start() { + if (serverId.isBlank()) { + LOGGER.warning("Presence responder not started because SIMPLECLOUD_UNIQUE_ID is missing"); + return; + } + if (!running.compareAndSet(false, true)) { + return; + } + + dispatcher = natsConnection.createDispatcher(null); + dispatcher.subscribe(subject, this::handleCompareRequest); + } + + public void registerPlayerProvider(ProxyPresencePlayerProvider playerProvider) { + this.playerProvider = Objects.requireNonNull(playerProvider, "playerProvider"); + } + + public void unregisterPlayerProvider() { + this.playerProvider = null; + } + + public void stop() { + if (!running.compareAndSet(true, false)) { + return; + } + if (dispatcher != null) { + dispatcher.unsubscribe(subject); + } + } + + private void handleCompareRequest(Message message) { + String replyTo = message.getReplyTo(); + if (replyTo == null || replyTo.isBlank()) { + return; + } + + try { + PresenceCompareRequest request = PresenceCompareRequest.parseFrom(message.getData()); + List players = currentPlayers(); + int localHash = computeHash(players); + boolean match = localHash == request.getHash(); + + LOGGER.info("[Presence] Compare request received — controller hash: " + request.getHash() + + ", local hash: " + localHash + ", match: " + match + + ", online players (" + players.size() + "): " + + players.stream().map(p -> p.getName() + "(" + p.getPlayerId() + ")").toList()); + + ProxyPresenceCompareResponse.Builder response = ProxyPresenceCompareResponse.newBuilder() + .setMatch(match); + + if (!match) { + response.addAllPlayers(players.stream().map(ProxyPresencePlayer::toProto).toList()); + } + + natsConnection.publish(replyTo, response.build().toByteArray()); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Failed to process presence compare request for " + subject, e); + } + } + + private List currentPlayers() { + ProxyPresencePlayerProvider currentProvider = playerProvider; + if (currentProvider == null) { + return List.of(); + } + + Collection suppliedPlayers = currentProvider.getProxyPresencePlayers(); + if (suppliedPlayers == null || suppliedPlayers.isEmpty()) { + return List.of(); + } + + return suppliedPlayers.stream() + .filter(Objects::nonNull) + .sorted(Comparator.comparing(ProxyPresencePlayer::hashRecord)) + .toList(); + } + + static int computeHash(Collection players) { + if (players == null || players.isEmpty()) { + return 0; + } + + int hash = FNV_32A_OFFSET_BASIS; + for (ProxyPresencePlayer player : players) { + byte[] bytes = player.hashRecord().getBytes(StandardCharsets.UTF_8); + for (byte currentByte : bytes) { + hash ^= currentByte & 0xff; + hash *= FNV_32A_PRIME; + } + } + return hash; + } +} diff --git a/api/src/main/java/app/simplecloud/api/internal/integration/presence/ProxyPresenceTracker.java b/api/src/main/java/app/simplecloud/api/internal/integration/presence/ProxyPresenceTracker.java new file mode 100644 index 0000000..613376c --- /dev/null +++ b/api/src/main/java/app/simplecloud/api/internal/integration/presence/ProxyPresenceTracker.java @@ -0,0 +1,86 @@ +package app.simplecloud.api.internal.integration.presence; + +import app.simplecloud.api.presence.ProxyPresencePlayer; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks stable per-player metadata needed for proxy presence reconciliation. + */ +public final class ProxyPresenceTracker { + + private final String connectedProxyName; + private final Map players = new ConcurrentHashMap<>(); + + public ProxyPresenceTracker(String connectedProxyName) { + this.connectedProxyName = connectedProxyName == null ? "" : connectedProxyName; + } + + public void trackLogin(String playerId) { + players.put(normalize(playerId), new TrackedPlayerMetadata(System.currentTimeMillis())); + } + + public void updateSessionId(String playerId, String sessionId) { + TrackedPlayerMetadata metadata = players.get(normalize(playerId)); + if (metadata != null) { + metadata.setSessionId(sessionId); + } + } + + public void remove(String playerId) { + players.remove(normalize(playerId)); + } + + public ProxyPresencePlayer createSnapshot( + String playerId, + String name, + String displayName, + String connectedServerName, + String clientLanguage, + int clientVersion, + boolean onlineMode + ) { + String normalizedPlayerId = normalize(playerId); + TrackedPlayerMetadata metadata = players.get(normalizedPlayerId); + + return new ProxyPresencePlayer( + normalizedPlayerId, + name, + displayName, + connectedServerName, + connectedProxyName, + metadata != null ? metadata.getLoginTimestampUnixMillis() : 0L, + clientLanguage, + clientVersion, + onlineMode, + metadata != null ? metadata.getSessionId() : "" + ); + } + + private static String normalize(String value) { + return value == null ? "" : value; + } + + private static final class TrackedPlayerMetadata { + + private final long loginTimestampUnixMillis; + private volatile String sessionId = ""; + + private TrackedPlayerMetadata(long loginTimestampUnixMillis) { + this.loginTimestampUnixMillis = loginTimestampUnixMillis; + } + + private long getLoginTimestampUnixMillis() { + return loginTimestampUnixMillis; + } + + private String getSessionId() { + return sessionId; + } + + private void setSessionId(String sessionId) { + this.sessionId = sessionId == null ? "" : sessionId; + } + } +} diff --git a/api/src/main/java/app/simplecloud/api/presence/ProxyPresencePlayer.java b/api/src/main/java/app/simplecloud/api/presence/ProxyPresencePlayer.java new file mode 100644 index 0000000..3117bc1 --- /dev/null +++ b/api/src/main/java/app/simplecloud/api/presence/ProxyPresencePlayer.java @@ -0,0 +1,81 @@ +package app.simplecloud.api.presence; + +import build.buf.gen.simplecloud.controller.v2.ProxyPresencePlayerSnapshot; + +/** + * Immutable player snapshot used for proxy presence reconciliation. + */ +public final class ProxyPresencePlayer { + + private final String playerId; + private final String name; + private final String displayName; + private final String connectedServerName; + private final String connectedProxyName; + private final long loginTimestampUnixMillis; + private final String clientLanguage; + private final int clientVersion; + private final boolean onlineMode; + private final String sessionId; + + public ProxyPresencePlayer( + String playerId, + String name, + String displayName, + String connectedServerName, + String connectedProxyName, + long loginTimestampUnixMillis, + String clientLanguage, + int clientVersion, + boolean onlineMode, + String sessionId + ) { + String normalizedDisplayName = normalize(displayName); + + this.playerId = normalize(playerId); + this.name = normalize(name); + this.displayName = normalizedDisplayName.isEmpty() ? this.name : normalizedDisplayName; + this.connectedServerName = normalize(connectedServerName); + this.connectedProxyName = normalize(connectedProxyName); + this.loginTimestampUnixMillis = Math.max(0L, loginTimestampUnixMillis); + this.clientLanguage = normalize(clientLanguage); + this.clientVersion = clientVersion; + this.onlineMode = onlineMode; + this.sessionId = normalize(sessionId); + } + + public String getPlayerId() { + return playerId; + } + + public String getName() { + return name; + } + + public String getConnectedServerName() { + return connectedServerName; + } + + public String hashRecord() { + return playerId + '\u001f' + connectedServerName; + } + + public ProxyPresencePlayerSnapshot toProto() { + return ProxyPresencePlayerSnapshot.newBuilder() + .setPlayerId(playerId) + .setName(name) + .setDisplayName(displayName) + .setConnectedServerName(connectedServerName) + .setConnectedProxyName(connectedProxyName) + .setLoginTimestampUnixMillis(loginTimestampUnixMillis) + .setClientLanguage(clientLanguage) + .setClientVersion(clientVersion) + .setOnlineMode(onlineMode) + .setSessionId(sessionId) + .build(); + } + + private static String normalize(String value) { + return value == null ? "" : value; + } +} diff --git a/api/src/main/java/app/simplecloud/api/presence/ProxyPresencePlayerProvider.java b/api/src/main/java/app/simplecloud/api/presence/ProxyPresencePlayerProvider.java new file mode 100644 index 0000000..fc2f36a --- /dev/null +++ b/api/src/main/java/app/simplecloud/api/presence/ProxyPresencePlayerProvider.java @@ -0,0 +1,11 @@ +package app.simplecloud.api.presence; + +import java.util.Collection; + +/** + * Supplies the current set of players connected through a proxy. + */ +public interface ProxyPresencePlayerProvider { + + Collection getProxyPresencePlayers(); +} diff --git a/api/src/main/java/app/simplecloud/api/runtime/SimpleCloudRuntime.java b/api/src/main/java/app/simplecloud/api/runtime/SimpleCloudRuntime.java new file mode 100644 index 0000000..d1fac8f --- /dev/null +++ b/api/src/main/java/app/simplecloud/api/runtime/SimpleCloudRuntime.java @@ -0,0 +1,40 @@ +package app.simplecloud.api.runtime; + +/** + * Resolves the runtime identity exposed by SimpleCloud environment variables. + */ +public final class SimpleCloudRuntime { + + private SimpleCloudRuntime() { + } + + public static String serverId() { + return value("SIMPLECLOUD_UNIQUE_ID"); + } + + public static String groupName() { + return value("SIMPLECLOUD_GROUP"); + } + + public static String numericalId() { + return value("SIMPLECLOUD_NUMERICAL_ID"); + } + + public static String serverName() { + String groupName = groupName(); + String numericalId = numericalId(); + + if (!groupName.isBlank() && !numericalId.isBlank()) { + return groupName + "-" + numericalId; + } + if (!groupName.isBlank()) { + return groupName; + } + return serverId(); + } + + private static String value(String key) { + String value = System.getenv(key); + return value == null ? "" : value.trim(); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2cc7890..a152f98 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ shadow = "8.3.3" openapi-generator = "7.14.0" caffeine = "3.1.8" -controller-proto = "32.0.0.1.20260221195311.aa530674289e" +controller-proto = "32.0.0.1.20260307111646.50c9870526a2" adventure-proto = "32.0.0.1.20260106101056.0d2c689e8f10" player-proto = "32.0.0.1.20260114162036.5bc1467b93a5" diff --git a/platform/bungeecord/src/main/java/app/simplecloud/api/platform/bungeecord/BungeeCordApiProvider.java b/platform/bungeecord/src/main/java/app/simplecloud/api/platform/bungeecord/BungeeCordApiProvider.java index f7803f7..e335fba 100644 --- a/platform/bungeecord/src/main/java/app/simplecloud/api/platform/bungeecord/BungeeCordApiProvider.java +++ b/platform/bungeecord/src/main/java/app/simplecloud/api/platform/bungeecord/BungeeCordApiProvider.java @@ -1,33 +1,52 @@ package app.simplecloud.api.platform.bungeecord; import app.simplecloud.api.CloudApi; +import app.simplecloud.api.internal.CloudApiImpl; import app.simplecloud.api.internal.integration.player.PlayerIntegration; +import app.simplecloud.api.internal.integration.presence.ProxyPresenceResponder; +import app.simplecloud.api.internal.integration.presence.ProxyPresenceTracker; +import app.simplecloud.api.presence.ProxyPresencePlayer; +import app.simplecloud.api.presence.ProxyPresencePlayerProvider; import app.simplecloud.api.player.CloudPlayer; +import app.simplecloud.api.runtime.SimpleCloudRuntime; import app.simplecloud.api.platform.shared.PlayerSynchronizer; import net.kyori.adventure.platform.bungeecord.BungeeAudiences; import net.md_5.bungee.api.chat.TextComponent; import net.md_5.bungee.api.connection.ProxiedPlayer; import net.md_5.bungee.api.plugin.Plugin; +import java.util.List; +import java.util.Locale; import java.util.UUID; import java.util.concurrent.CompletableFuture; -public class BungeeCordApiProvider extends Plugin { +public class BungeeCordApiProvider extends Plugin implements ProxyPresencePlayerProvider { - private CloudApi cloudApi; + private CloudApiImpl cloudApi; + private String proxyName; private PlayerSynchronizer playerSynchronizer; private PlayerIntegration playerIntegration; + private ProxyPresenceTracker proxyPresenceTracker; + private ProxyPresenceResponder proxyPresenceResponder; private BungeeAudiences bungeeAudiences; @Override public void onEnable() { - this.cloudApi = CloudApi.create(); + this.cloudApi = (CloudApiImpl) CloudApi.create(); + this.proxyName = SimpleCloudRuntime.serverName(); this.playerSynchronizer = new PlayerSynchronizer( cloudApi, () -> (long) getProxy().getOnlineCount() ); this.playerIntegration = new PlayerIntegration(cloudApi); + this.proxyPresenceTracker = new ProxyPresenceTracker(proxyName); + this.proxyPresenceResponder = new ProxyPresenceResponder( + cloudApi.getNatsConnection(), + cloudApi.getNetworkId(), + SimpleCloudRuntime.serverId(), + this + ); playerIntegration.onKick(this::handleKickRequest); playerIntegration.onConnect(this::handleConnectRequest); @@ -35,21 +54,52 @@ public void onEnable() { this.bungeeAudiences = BungeeAudiences.create(this); getLogger().info("SimpleCloud v3 API provider initialized!"); - getProxy().getPluginManager().registerListener(this, new PlayerConnectionListener(playerSynchronizer, playerIntegration)); + getProxy().getPluginManager().registerListener(this, new PlayerConnectionListener( + playerSynchronizer, + playerIntegration, + proxyPresenceTracker, + proxyName + )); playerSynchronizer.start(); playerIntegration.start(); + proxyPresenceResponder.start(); } @Override public void onDisable() { getLogger().info("SimpleCloud v3 API provider uninitialized!"); + proxyPresenceResponder.stop(); playerSynchronizer.stop(); playerIntegration.stop(); if (bungeeAudiences != null) { bungeeAudiences.close(); } + cloudApi.close(); + } + + @Override + public List getProxyPresencePlayers() { + return getProxy().getPlayers().stream() + .map(this::toPresencePlayer) + .toList(); + } + + private ProxyPresencePlayer toPresencePlayer(ProxiedPlayer player) { + String connectedServerName = player.getServer() != null ? player.getServer().getInfo().getName() : ""; + Locale locale = player.getLocale(); + var pendingConnection = player.getPendingConnection(); + + return proxyPresenceTracker.createSnapshot( + player.getUniqueId().toString(), + player.getName(), + player.getDisplayName(), + connectedServerName, + locale != null ? locale.toString() : "en_US", + pendingConnection != null ? pendingConnection.getVersion() : 0, + pendingConnection != null && pendingConnection.isOnlineMode() + ); } private CompletableFuture handleKickRequest(String playerUniqueId, String reason) { @@ -88,5 +138,3 @@ private CompletableFuture handleConnectRequest(String return future; } } - - diff --git a/platform/bungeecord/src/main/java/app/simplecloud/api/platform/bungeecord/PlayerConnectionListener.java b/platform/bungeecord/src/main/java/app/simplecloud/api/platform/bungeecord/PlayerConnectionListener.java index 8a2ea37..4b56395 100644 --- a/platform/bungeecord/src/main/java/app/simplecloud/api/platform/bungeecord/PlayerConnectionListener.java +++ b/platform/bungeecord/src/main/java/app/simplecloud/api/platform/bungeecord/PlayerConnectionListener.java @@ -1,6 +1,7 @@ package app.simplecloud.api.platform.bungeecord; import app.simplecloud.api.internal.integration.player.PlayerIntegration; +import app.simplecloud.api.internal.integration.presence.ProxyPresenceTracker; import app.simplecloud.api.platform.shared.PlayerSynchronizer; import net.md_5.bungee.api.connection.PendingConnection; import net.md_5.bungee.api.connection.ProxiedPlayer; @@ -21,30 +22,42 @@ public class PlayerConnectionListener implements Listener { private final PlayerSynchronizer playerSynchronizer; private final PlayerIntegration playerIntegration; - private final String proxyId; - - public PlayerConnectionListener(PlayerSynchronizer playerSynchronizer, PlayerIntegration playerIntegration) { + private final ProxyPresenceTracker proxyPresenceTracker; + private final String proxyName; + + public PlayerConnectionListener( + PlayerSynchronizer playerSynchronizer, + PlayerIntegration playerIntegration, + ProxyPresenceTracker proxyPresenceTracker, + String proxyName + ) { this.playerSynchronizer = playerSynchronizer; this.playerIntegration = playerIntegration; - this.proxyId = System.getenv("SIMPLECLOUD_UNIQUE_ID"); + this.proxyPresenceTracker = proxyPresenceTracker; + this.proxyName = proxyName; } @EventHandler(priority = EventPriority.LOW) public void onPlayerJoin(PostLoginEvent event) { ProxiedPlayer player = event.getPlayer(); PendingConnection connection = player.getPendingConnection(); + String playerId = player.getUniqueId().toString(); + proxyPresenceTracker.trackLogin(playerId); playerIntegration.login( - player.getUniqueId().toString(), + playerId, player.getName(), player.getDisplayName(), - proxyId != null ? proxyId : "unknown", + proxyName != null && !proxyName.isBlank() ? proxyName : "unknown", String.valueOf(connection.getSocketAddress().hashCode()), - connection.getListener().getDefaultServer(), + player.getLocale() != null ? player.getLocale().toString() : "en_US", connection.getVersion(), connection.isOnlineMode(), null ).thenAccept(result -> { + if (result.isSuccess()) { + proxyPresenceTracker.updateSessionId(playerId, result.getSessionId()); + } if (!result.isSuccess()) { logger.warning("Login failed for " + player.getName() + ": " + result.getErrorMessage()); } @@ -59,12 +72,14 @@ public void onPlayerJoin(PostLoginEvent event) { @EventHandler public void onPlayerQuit(PlayerDisconnectEvent event) { ProxiedPlayer player = event.getPlayer(); + String playerId = player.getUniqueId().toString(); - playerIntegration.disconnect(player.getUniqueId().toString()) + playerIntegration.disconnect(playerId) .exceptionally(e -> { logger.log(Level.SEVERE, "Failed to send disconnect event for " + player.getName(), e); return null; }); + proxyPresenceTracker.remove(playerId); CompletableFuture.runAsync(() -> playerSynchronizer.updatePlayerCount()); } @@ -83,5 +98,3 @@ public void onServerSwitch(ServerSwitchEvent event) { } } } - - diff --git a/platform/paper/src/main/java/app/simplecloud/api/provider/paper/PaperApiProvider.java b/platform/paper/src/main/java/app/simplecloud/api/provider/paper/PaperApiProvider.java index 2aac904..30b1f0e 100644 --- a/platform/paper/src/main/java/app/simplecloud/api/provider/paper/PaperApiProvider.java +++ b/platform/paper/src/main/java/app/simplecloud/api/provider/paper/PaperApiProvider.java @@ -2,6 +2,7 @@ import app.simplecloud.api.CloudApi; import app.simplecloud.api.internal.integration.adventure.AdventureIntegration; +import app.simplecloud.api.runtime.SimpleCloudRuntime; import net.kyori.adventure.audience.Audience; import org.bukkit.Bukkit; import org.bukkit.plugin.java.JavaPlugin; @@ -15,12 +16,8 @@ public class PaperApiProvider extends JavaPlugin { public void onEnable() { this.cloudApi = CloudApi.create(); - String serverId = System.getenv("SIMPLECLOUD_UNIQUE_ID"); - if (serverId == null) { - serverId = "unknown"; - } - - String groupName = System.getenv("SIMPLECLOUD_GROUP"); + String serverId = SimpleCloudRuntime.serverId(); + String groupName = SimpleCloudRuntime.groupName(); this.adventureIntegration = AdventureIntegration.builder(cloudApi) .playerResolver(Bukkit::getPlayer) diff --git a/platform/shared/src/main/java/app/simplecloud/api/platform/shared/PlayerSynchronizer.java b/platform/shared/src/main/java/app/simplecloud/api/platform/shared/PlayerSynchronizer.java index 2f565e4..ed3c94a 100644 --- a/platform/shared/src/main/java/app/simplecloud/api/platform/shared/PlayerSynchronizer.java +++ b/platform/shared/src/main/java/app/simplecloud/api/platform/shared/PlayerSynchronizer.java @@ -1,6 +1,7 @@ package app.simplecloud.api.platform.shared; import app.simplecloud.api.CloudApi; +import app.simplecloud.api.runtime.SimpleCloudRuntime; import java.util.HashMap; import java.util.Map; @@ -23,7 +24,7 @@ public class PlayerSynchronizer { public PlayerSynchronizer(CloudApi cloudApi, Supplier getCurrentOnlineCount) { this.cloudApi = cloudApi; this.getCurrentOnlineCount = getCurrentOnlineCount; - this.currentServerId = System.getenv("SIMPLECLOUD_UNIQUE_ID"); + this.currentServerId = SimpleCloudRuntime.serverId(); } public void start() { @@ -99,4 +100,3 @@ public void stop() { } } } - diff --git a/platform/velocity/src/main/java/app/simplecloud/api/platform/velocity/CloudApiVelocityPlugin.java b/platform/velocity/src/main/java/app/simplecloud/api/platform/velocity/CloudApiVelocityPlugin.java index 71de254..b4192a8 100644 --- a/platform/velocity/src/main/java/app/simplecloud/api/platform/velocity/CloudApiVelocityPlugin.java +++ b/platform/velocity/src/main/java/app/simplecloud/api/platform/velocity/CloudApiVelocityPlugin.java @@ -1,8 +1,14 @@ package app.simplecloud.api.platform.velocity; import app.simplecloud.api.CloudApi; +import app.simplecloud.api.internal.CloudApiImpl; import app.simplecloud.api.internal.integration.player.PlayerIntegration; +import app.simplecloud.api.internal.integration.presence.ProxyPresenceResponder; +import app.simplecloud.api.internal.integration.presence.ProxyPresenceTracker; +import app.simplecloud.api.presence.ProxyPresencePlayer; +import app.simplecloud.api.presence.ProxyPresencePlayerProvider; import app.simplecloud.api.player.CloudPlayer; +import app.simplecloud.api.runtime.SimpleCloudRuntime; import app.simplecloud.api.platform.shared.PlayerSynchronizer; import com.google.inject.Inject; import com.velocitypowered.api.event.Subscribe; @@ -15,6 +21,8 @@ import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import org.slf4j.Logger; +import java.util.List; +import java.util.Locale; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -24,24 +32,35 @@ version = "1.0", authors = {"Fllip"} ) -public class CloudApiVelocityPlugin { +public class CloudApiVelocityPlugin implements ProxyPresencePlayerProvider { private final Logger logger; private final ProxyServer proxyServer; - private final CloudApi cloudApi; + private final CloudApiImpl cloudApi; + private final String proxyName; private final PlayerSynchronizer playerSynchronizer; private final PlayerIntegration playerIntegration; + private final ProxyPresenceTracker proxyPresenceTracker; + private final ProxyPresenceResponder proxyPresenceResponder; @Inject public CloudApiVelocityPlugin(Logger logger, ProxyServer proxyServer) { this.logger = logger; this.proxyServer = proxyServer; - this.cloudApi = CloudApi.create(); + this.cloudApi = (CloudApiImpl) CloudApi.create(); + this.proxyName = SimpleCloudRuntime.serverName(); this.playerSynchronizer = new PlayerSynchronizer( cloudApi, () -> (long) proxyServer.getPlayerCount() ); this.playerIntegration = new PlayerIntegration(cloudApi); + this.proxyPresenceTracker = new ProxyPresenceTracker(proxyName); + this.proxyPresenceResponder = new ProxyPresenceResponder( + cloudApi.getNatsConnection(), + cloudApi.getNetworkId(), + SimpleCloudRuntime.serverId(), + this + ); playerIntegration.onKick(this::handleKickRequest); playerIntegration.onConnect(this::handleConnectRequest); @@ -50,17 +69,49 @@ public CloudApiVelocityPlugin(Logger logger, ProxyServer proxyServer) { @Subscribe public void onProxyInitialize(ProxyInitializeEvent event) { logger.info("SimpleCloud v3 API provider initialized!"); - proxyServer.getEventManager().register(this, new PlayerConnectionListener(playerSynchronizer, playerIntegration)); + proxyServer.getEventManager().register(this, new PlayerConnectionListener( + playerSynchronizer, + playerIntegration, + proxyPresenceTracker, + proxyName + )); playerSynchronizer.start(); playerIntegration.start(); + proxyPresenceResponder.start(); } @Subscribe public void onProxyShutdown(ProxyShutdownEvent event) { logger.info("SimpleCloud v3 API provider uninitialized!"); + proxyPresenceResponder.stop(); playerSynchronizer.stop(); playerIntegration.stop(); + cloudApi.close(); + } + + @Override + public List getProxyPresencePlayers() { + return proxyServer.getAllPlayers().stream() + .map(this::toPresencePlayer) + .toList(); + } + + private ProxyPresencePlayer toPresencePlayer(Player player) { + String connectedServerName = player.getCurrentServer() + .map(serverConnection -> serverConnection.getServerInfo().getName()) + .orElse(""); + Locale locale = player.getEffectiveLocale(); + + return proxyPresenceTracker.createSnapshot( + player.getUniqueId().toString(), + player.getUsername(), + player.getUsername(), + connectedServerName, + locale != null ? locale.toString() : "en_US", + player.getProtocolVersion().getProtocol(), + player.isOnlineMode() + ); } private CompletableFuture handleKickRequest(String playerUniqueId, String reason) { @@ -99,4 +150,3 @@ private CompletableFuture handleConnectRequest(String }); } } - diff --git a/platform/velocity/src/main/java/app/simplecloud/api/platform/velocity/PlayerConnectionListener.java b/platform/velocity/src/main/java/app/simplecloud/api/platform/velocity/PlayerConnectionListener.java index 592c179..d936331 100644 --- a/platform/velocity/src/main/java/app/simplecloud/api/platform/velocity/PlayerConnectionListener.java +++ b/platform/velocity/src/main/java/app/simplecloud/api/platform/velocity/PlayerConnectionListener.java @@ -1,6 +1,7 @@ package app.simplecloud.api.platform.velocity; import app.simplecloud.api.internal.integration.player.PlayerIntegration; +import app.simplecloud.api.internal.integration.presence.ProxyPresenceTracker; import app.simplecloud.api.platform.shared.PlayerSynchronizer; import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.connection.DisconnectEvent; @@ -18,17 +19,26 @@ public class PlayerConnectionListener { private final PlayerSynchronizer playerSynchronizer; private final PlayerIntegration playerIntegration; - private final String proxyId; - - public PlayerConnectionListener(PlayerSynchronizer playerSynchronizer, PlayerIntegration playerIntegration) { + private final ProxyPresenceTracker proxyPresenceTracker; + private final String proxyName; + + public PlayerConnectionListener( + PlayerSynchronizer playerSynchronizer, + PlayerIntegration playerIntegration, + ProxyPresenceTracker proxyPresenceTracker, + String proxyName + ) { this.playerSynchronizer = playerSynchronizer; this.playerIntegration = playerIntegration; - this.proxyId = System.getenv("SIMPLECLOUD_UNIQUE_ID"); + this.proxyPresenceTracker = proxyPresenceTracker; + this.proxyName = proxyName; } @Subscribe public void onPlayerJoin(PostLoginEvent event) { Player player = event.getPlayer(); + String playerId = player.getUniqueId().toString(); + proxyPresenceTracker.trackLogin(playerId); String texture = null; var texturesProperty = player.getGameProfile().getProperties().stream() @@ -39,16 +49,19 @@ public void onPlayerJoin(PostLoginEvent event) { } playerIntegration.login( - player.getUniqueId().toString(), + playerId, player.getUsername(), player.getUsername(), - proxyId != null ? proxyId : "unknown", + proxyName != null && !proxyName.isBlank() ? proxyName : "unknown", String.valueOf(player.getRemoteAddress().hashCode()), player.getEffectiveLocale() != null ? player.getEffectiveLocale().toString() : "en_US", player.getProtocolVersion().getProtocol(), player.isOnlineMode(), texture ).thenAccept(result -> { + if (result.isSuccess()) { + proxyPresenceTracker.updateSessionId(playerId, result.getSessionId()); + } if (!result.isSuccess()) { logger.warn("Login failed for {}: {}", player.getUsername(), result.getErrorMessage()); } @@ -63,12 +76,14 @@ public void onPlayerJoin(PostLoginEvent event) { @Subscribe public void onPlayerQuit(DisconnectEvent event) { Player player = event.getPlayer(); + String playerId = player.getUniqueId().toString(); - playerIntegration.disconnect(player.getUniqueId().toString()) + playerIntegration.disconnect(playerId) .exceptionally(e -> { logger.error("Failed to send disconnect event for {}: {}", player.getUsername(), e.getMessage()); return null; }); + proxyPresenceTracker.remove(playerId); CompletableFuture.runAsync(() -> playerSynchronizer.updatePlayerCount()); } @@ -85,5 +100,3 @@ public void onServerConnected(ServerConnectedEvent event) { }); } } - -