Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ constructor(
logger = logger,
playerPresenceService = playerPresenceService,
messages = messages,
proxy = proxy,
plugin = this,
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,41 @@ 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
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

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<UUID> = ConcurrentHashMap.newKeySet()

@Subscribe
fun onPreLogin(event: PreLoginEvent): EventTask {
val name = event.username
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -96,6 +121,7 @@ class PlayerConnectionListener(
name,
reply.status,
)
watchForAbandonedLogin(playerId, name)
return true
}
LoginStatus.LOGIN_STATUS_ALREADY_ONLINE -> messages.alreadyOnline
Expand Down Expand Up @@ -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<UUID>,
playerId: UUID,
playerOnline: Boolean,
): Boolean {
val wasPending = pending.remove(playerId)
return wasPending && !playerOnline
}
}
}
Original file line number Diff line number Diff line change
@@ -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<UUID> =
ConcurrentHashMap.newKeySet<UUID>().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",
)
}
}