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
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ import java.time.Duration
* - `GROUNDS_AGONES_ADDRESS_TYPE` — Which `status.addresses` entry to use (`PodIP`, `ExternalIP`,
* `InternalIP`, `Hostname`).
* - `GROUNDS_AGONES_PORT` — TCP port to dial on the discovered GameServer.
* - `GROUNDS_AGONES_LOBBY_SOFT_CAP` — Players a lobby is packed up to before joins go to the next
* one. Soft: a snapshot-raced join over the cap is fine.
*/
data class DiscoveryConfig(
val namespace: String,
Expand All @@ -31,7 +29,6 @@ data class DiscoveryConfig(
val pollInterval: Duration,
val addressType: String,
val port: Int,
val lobbySoftCap: Int,
) {
companion object {
const val DEFAULT_NAMESPACE = "games"
Expand All @@ -42,7 +39,6 @@ data class DiscoveryConfig(
val DEFAULT_POLL_INTERVAL: Duration = Duration.ofSeconds(2)
const val DEFAULT_ADDRESS_TYPE = "PodIP"
const val DEFAULT_PORT = 25565
const val DEFAULT_LOBBY_SOFT_CAP = 400

fun fromEnv(env: Map<String, String> = System.getenv()): DiscoveryConfig =
DiscoveryConfig(
Expand All @@ -63,9 +59,6 @@ data class DiscoveryConfig(
?: DEFAULT_POLL_INTERVAL,
addressType = env["GROUNDS_AGONES_ADDRESS_TYPE"] ?: DEFAULT_ADDRESS_TYPE,
port = env["GROUNDS_AGONES_PORT"]?.toIntOrNull() ?: DEFAULT_PORT,
lobbySoftCap =
env["GROUNDS_AGONES_LOBBY_SOFT_CAP"]?.toIntOrNull()?.takeIf { it > 0 }
?: DEFAULT_LOBBY_SOFT_CAP,
)

private val DURATION_PATTERN = Regex("""^(\d+)\s*(s|m|h)$""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@ import net.kyori.adventure.text.Component
class DiscoveryPlayerListener(
private val proxyServer: ProxyServer,
private val lobbyServers: Set<String>,
private val lobbySoftCap: Int,
/**
* Network-wide players per backend server, or null when the network cannot be asked. Null falls
* back to this proxy's own view — enough to keep packing roughly right on a single proxy, and
* strictly better than picking blind.
* back to this proxy's own view — on a single proxy that is the same number, and with several
* it still spreads, just per proxy rather than per network.
*/
private val networkCounts: () -> Map<String, Int>?,
) {
Expand Down Expand Up @@ -50,12 +49,12 @@ class DiscoveryPlayerListener(
val candidates =
lobbies.map { server ->
val name = server.serverInfo.name
LobbyPacking.Candidate(
LobbySelection.Candidate(
name,
if (counts != null) counts[name] ?: 0 else server.playersConnected.size,
)
}
val chosen = LobbyPacking.pick(candidates, lobbySoftCap) ?: return null
val chosen = LobbySelection.pick(candidates) ?: return null
return lobbies.firstOrNull { it.serverInfo.name == chosen }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,7 @@ class DiscoveryService(
private fun registerListeners() {
proxyServer.eventManager.register(
plugin,
DiscoveryPlayerListener(
proxyServer,
lobbyServers,
config.lobbySoftCap,
this::networkCountsCached,
),
DiscoveryPlayerListener(proxyServer, lobbyServers, this::networkCountsCached),
)
}

Expand Down
28 changes: 0 additions & 28 deletions velocity/src/main/kotlin/gg/grounds/discovery/LobbyPacking.kt

This file was deleted.

29 changes: 29 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/discovery/LobbySelection.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package gg.grounds.discovery

/**
* Which lobby a joining player should land on.
*
* Least-occupied wins. Joins spread across the lobbies instead of filling one and then the next, so
* no single instance holds the whole region: a lobby restart takes a share of the players with it
* rather than all of them.
*
* This also gives a newly autoscaled lobby what it needs without any special case. A fresh lobby is
* empty, so it is the least occupied, so it takes joins until it has caught up with the others —
* priority filling falls out of the same rule that does the spreading.
*
* This replaces a fullest-first-below-a-cap policy. That one existed so a network of 50 would feel
* like one lobby of 50 rather than five of ten, which is a real concern — but it meant that in
* practice every player in a region sat in one process, and measured on stage the lobby was never
* the reason to split: 228 players cost 0.29 cores and a 2 ms average tick against a 50 ms budget.
* The reason to split is blast radius, and that argues for spreading always rather than for a
* threshold nobody ever reached.
*
* Ties break on the name so that every proxy, working from the same counts, makes the same choice.
*/
object LobbySelection {

data class Candidate(val name: String, val players: Int)

fun pick(candidates: List<Candidate>): String? =
candidates.minWithOrNull(compareBy({ it.players }, { it.name }))?.name
}
58 changes: 0 additions & 58 deletions velocity/src/test/kotlin/gg/grounds/discovery/LobbyPackingTest.kt

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package gg.grounds.discovery

import gg.grounds.discovery.LobbySelection.Candidate
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test

class LobbySelectionTest {

@Test
fun `joins go to the least occupied lobby`() {
val chosen =
LobbySelection.pick(
listOf(Candidate("lobby-a", 12), Candidate("lobby-b", 391), Candidate("lobby-c", 3))
)
assertEquals("lobby-c", chosen)
}

@Test
fun `a freshly autoscaled lobby is filled first because it is empty`() {
val chosen =
LobbySelection.pick(
listOf(
Candidate("lobby-a", 150),
Candidate("lobby-b", 148),
Candidate("lobby-new", 0),
)
)
assertEquals("lobby-new", chosen)
}

@Test
fun `repeated picks even the lobbies out rather than filling one`() {
val counts = mutableMapOf("lobby-a" to 4, "lobby-b" to 0, "lobby-c" to 2)
repeat(6) {
val chosen = LobbySelection.pick(counts.map { Candidate(it.key, it.value) })!!
counts[chosen] = counts.getValue(chosen) + 1
}
assertEquals(listOf(4, 4, 4), counts.values.sorted())
}

@Test
fun `ties break on the name so every proxy makes the same choice`() {
val chosen = LobbySelection.pick(listOf(Candidate("lobby-b", 50), Candidate("lobby-a", 50)))
assertEquals("lobby-a", chosen)
}

@Test
fun `no candidates means no lobby`() {
assertNull(LobbySelection.pick(emptyList()))
}
}