diff --git a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt index 21076eb..a864e2a 100644 --- a/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt +++ b/velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt @@ -64,6 +64,8 @@ constructor( logger = logger, playerPresenceService = playerPresenceService, messages = messages, + proxy = proxy, + plugin = this, ), ) diff --git a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt index ae7ef70..7a6f1a4 100644 --- a/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/listener/PlayerConnectionListener.kt @@ -3,8 +3,10 @@ package gg.grounds.listener import com.velocitypowered.api.event.EventTask import com.velocitypowered.api.event.Subscribe import com.velocitypowered.api.event.connection.DisconnectEvent +import com.velocitypowered.api.event.connection.PostLoginEvent import com.velocitypowered.api.event.connection.PreLoginEvent import com.velocitypowered.api.event.player.ServerConnectedEvent +import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.util.UuidUtils import gg.grounds.config.MessagesConfig import gg.grounds.grpc.player.LoginStatus @@ -12,6 +14,8 @@ import gg.grounds.grpc.player.PlayerLoginReply import gg.grounds.player.presence.PlayerLoginResult import gg.grounds.presence.PlayerPresenceService import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit import net.kyori.adventure.text.Component import org.slf4j.Logger @@ -19,7 +23,21 @@ class PlayerConnectionListener( private val logger: Logger, private val playerPresenceService: PlayerPresenceService, private val messages: MessagesConfig, + private val proxy: ProxyServer, + private val plugin: Any, + private val abandonedLoginGraceSeconds: Long = DEFAULT_ABANDONED_LOGIN_GRACE_SECONDS, ) { + /** + * Players whose session this proxy created at pre-login and who have not become a [Player] yet. + * + * The pair that keeps presence honest is pre-login → disconnect, and it has a hole: Velocity + * only fires DisconnectEvent for connections that finished logging in. A client that dies in + * between — an expired Mojang session, a crash, a pulled cable — leaves the row behind, and + * every retry is then refused with "you are already online" until the 90s TTL reaps it. So the + * proxy that created the row watches it: no player after the grace period, no session. + */ + private val pendingLogins: MutableSet = ConcurrentHashMap.newKeySet() + @Subscribe fun onPreLogin(event: PreLoginEvent): EventTask { val name = event.username @@ -56,8 +74,15 @@ class PlayerConnectionListener( } } + /** The connection became a real player — the disconnect path owns the session from here. */ + @Subscribe + fun onPostLogin(event: PostLoginEvent) { + pendingLogins.remove(event.player.uniqueId) + } + @Subscribe fun onDisconnect(event: DisconnectEvent): EventTask { + pendingLogins.remove(event.player.uniqueId) val playerId = event.player.uniqueId val name = event.player.username @@ -96,6 +121,7 @@ class PlayerConnectionListener( name, reply.status, ) + watchForAbandonedLogin(playerId, name) return true } LoginStatus.LOGIN_STATUS_ALREADY_ONLINE -> messages.alreadyOnline @@ -133,4 +159,49 @@ class PlayerConnectionListener( } private fun proxyId(): String = System.getenv("PROXY_ID") ?: "" + + private fun watchForAbandonedLogin(playerId: UUID, name: String) { + pendingLogins.add(playerId) + proxy.scheduler + .buildTask(plugin, Runnable { releaseAbandonedLogin(playerId, name) }) + .delay(abandonedLoginGraceSeconds, TimeUnit.SECONDS) + .schedule() + } + + private fun releaseAbandonedLogin(playerId: UUID, name: String) { + val online = proxy.getPlayer(playerId).isPresent + if (!shouldReleaseAbandonedLogin(pendingLogins, playerId, online)) return + + val result = playerPresenceService.logout(playerId) + logger.info( + "Player session released after abandoned login (playerId={}, username={}, removed={})", + playerId, + name, + result?.removed, + ) + } + + companion object { + /** + * Long enough for a normal login to finish (auth, config phase, first server connect), + * short enough that a player retrying after a crash is not locked out. + */ + const val DEFAULT_ABANDONED_LOGIN_GRACE_SECONDS = 15L + + /** + * True when the session this proxy created belongs to a connection that never arrived. + * + * Consumes the pending marker either way, so a second run cannot log out a player who + * reconnected in the meantime. + */ + @JvmStatic + fun shouldReleaseAbandonedLogin( + pending: MutableSet, + playerId: UUID, + playerOnline: Boolean, + ): Boolean { + val wasPending = pending.remove(playerId) + return wasPending && !playerOnline + } + } } diff --git a/velocity/src/test/kotlin/gg/grounds/listener/PlayerConnectionListenerTest.kt b/velocity/src/test/kotlin/gg/grounds/listener/PlayerConnectionListenerTest.kt new file mode 100644 index 0000000..29388ab --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/listener/PlayerConnectionListenerTest.kt @@ -0,0 +1,58 @@ +package gg.grounds.listener + +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PlayerConnectionListenerTest { + private fun pendingSet(vararg ids: UUID): MutableSet = + ConcurrentHashMap.newKeySet().apply { addAll(ids) } + + @Test + fun releasesTheSessionWhenTheConnectionNeverBecameAPlayer() { + val playerId = UUID.randomUUID() + val pending = pendingSet(playerId) + + assertTrue( + PlayerConnectionListener.shouldReleaseAbandonedLogin(pending, playerId, false), + "a pre-login session with no player behind it must be released", + ) + assertFalse(pending.contains(playerId)) + } + + @Test + fun keepsTheSessionOfAPlayerWhoActuallyJoined() { + val playerId = UUID.randomUUID() + val pending = pendingSet(playerId) + + assertFalse( + PlayerConnectionListener.shouldReleaseAbandonedLogin(pending, playerId, true), + "the player is online - releasing would log out a live session", + ) + } + + @Test + fun neverReleasesASessionThisProxyDidNotCreate() { + // The duplicate-login case: a second connection is denied at pre-login and never becomes + // pending. Releasing on its behalf would log out the player who is legitimately online. + val playerId = UUID.randomUUID() + + assertFalse( + PlayerConnectionListener.shouldReleaseAbandonedLogin(pendingSet(), playerId, false) + ) + } + + @Test + fun releasesOnlyOnce() { + val playerId = UUID.randomUUID() + val pending = pendingSet(playerId) + + assertTrue(PlayerConnectionListener.shouldReleaseAbandonedLogin(pending, playerId, false)) + assertFalse( + PlayerConnectionListener.shouldReleaseAbandonedLogin(pending, playerId, false), + "a second run must not log out a player who reconnected in the meantime", + ) + } +}