Skip to content

Commit

Permalink
Rework automatic game/instance cleanup and kick players if they try t…
Browse files Browse the repository at this point in the history
…o join an unregistered instance
  • Loading branch information
FluxCapacitor2 committed Dec 24, 2024
1 parent 2bd9be8 commit fcb599f
Show file tree
Hide file tree
Showing 6 changed files with 87 additions and 83 deletions.
92 changes: 35 additions & 57 deletions common/src/main/kotlin/com/bluedragonmc/server/Game.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import net.minestom.server.event.server.ServerTickMonitorEvent
import net.minestom.server.event.trait.InstanceEvent
import net.minestom.server.event.trait.PlayerEvent
import net.minestom.server.instance.Instance
import net.minestom.server.tag.Tag
import net.minestom.server.timer.ExecutionType
import net.minestom.server.utils.async.AsyncUtils
import org.slf4j.Logger
Expand Down Expand Up @@ -234,9 +233,8 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n
}.delay(delay).schedule()
}

open fun endGame(queueAllPlayers: Boolean = true) {

// Log some information about the game in the database
private fun logGameInfo() {
if (!::startTime.isInitialized) return

val statHistory = getModuleOrNull<StatisticsModule>()?.getHistory()
val teams = getModuleOrNull<TeamModule>()?.teams?.map { team ->
Expand Down Expand Up @@ -269,24 +267,27 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n
}

Database.IO.launch {
if (::startTime.isInitialized) {
Database.connection.logGame(
GameDocument(
gameId = id,
serverId = Environment.getServerName(),
gameType = name,
mapName = mapName,
mode = mode,
statistics = statHistory,
teams = teams,
winningTeam = winningTeamRecord,
startTime = startTime,
endTime = Date(),
instances = instanceRecords
)
Database.connection.logGame(
GameDocument(
gameId = id,
serverId = Environment.getServerName(),
gameType = name,
mapName = mapName,
mode = mode,
statistics = statHistory,
teams = teams,
winningTeam = winningTeamRecord,
startTime = startTime,
endTime = Date(),
instances = instanceRecords
)
}
)
}
}

open fun endGame(queueAllPlayers: Boolean = true) {

logGameInfo()

state = GameState.ENDING
games.remove(this)
Expand All @@ -312,8 +313,10 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n

MinecraftServer.getSchedulerManager().buildTask {
instancesToRemove.forEach { instance ->
logger.info("Forcefully unregistering instance ${instance.uniqueId}...")
InstanceUtils.forceUnregisterInstance(instance)
if (instance.isRegistered) {
logger.info("Forcefully unregistering instance ${instance.uniqueId}...")
InstanceUtils.forceUnregisterInstance(instance)
}
}
}.executionType(ExecutionType.TICK_START).delay(Duration.ofSeconds(10))

Expand Down Expand Up @@ -375,13 +378,6 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n
*/
private val INSTANCE_CLEANUP_PERIOD = System.getenv("SERVER_INSTANCE_CLEANUP_PERIOD")?.toLongOrNull() ?: 10_000L

/**
* Instances must be inactive for at least 2 minutes
* to be cleaned up (by default).
*/
private val CLEANUP_MIN_INACTIVE_TIME =
System.getenv("SERVER_INSTANCE_MIN_INACTIVE_TIME")?.toLongOrNull() ?: 120_000L

fun findGame(player: Player): Game? =
games.find { player in it.players || it.ownsInstance(player.instance ?: return@find false) }

Expand All @@ -392,8 +388,6 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n

fun findGame(gameId: String): Game? = games.find { it.id == gameId }

private val INACTIVE_SINCE_TAG = Tag.Long("instance_inactive_since")

init {
MinecraftServer.getSchedulerManager().buildShutdownTask {
ArrayList(games).forEach { game ->
Expand All @@ -403,37 +397,21 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n

timer("Cleanup", daemon = true, period = INSTANCE_CLEANUP_PERIOD) {
val instances = MinecraftServer.getInstanceManager().instances
val games = ArrayList(games) // Copy to avoid CME

instances.forEach { instance ->
val owner = findGame(instance.uniqueId)
// Remove empty instances which are not owned by a game OR are owned by an inactive game.
if ((owner == null || owner.isInactive()) && instance.players.isEmpty()) {
// Only instances that are not required by any game should be removed.
if (games.none { it.getRequiredInstances().contains(instance) }) {
val inactiveSince = instance.getTag(INACTIVE_SINCE_TAG)
if (inactiveSince == null) {
// The instance has recently turned inactive.
instance.setTag(INACTIVE_SINCE_TAG, System.currentTimeMillis())
} else {
val duration = System.currentTimeMillis() - inactiveSince
if (duration >= CLEANUP_MIN_INACTIVE_TIME) {
// Instances inactive for more than the minimum inactive time should be removed.
logger.info("Removing inactive instance ${instance.uniqueId}")
InstanceUtils.forceUnregisterInstance(instance)
}
}
}
} else instance.removeTag(INACTIVE_SINCE_TAG)
}

// End all inactive games with no owned instances
games.forEach { game ->
if (game.isInactive() && game.getOwnedInstances().isEmpty()) {
logger.info("Removing inactive game ${game.id} (${game.name}/${game.mapName}/${game.mode})")
if (game.isInactive()) {
logger.info("Ending inactive game ${game.id} (${game.name}/${game.mapName}/${game.mode})")
game.endGame(false)
}
}

instances.forEach { instance ->
val owner = games.find { it.ownsInstance(instance) }
if (owner == null && games.none { it.getRequiredInstances().contains(instance) }) {
logger.info("Removing orphan instance ${instance.uniqueId} (${instance})")
InstanceUtils.forceUnregisterInstance(instance)
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ package com.bluedragonmc.server.module.map

import com.bluedragonmc.server.Game
import com.bluedragonmc.server.module.GameModule
import com.bluedragonmc.server.utils.InstanceUtils
import net.minestom.server.MinecraftServer
import net.minestom.server.event.Event
import net.minestom.server.event.EventNode
import net.minestom.server.instance.*
import net.minestom.server.event.instance.InstanceUnregisterEvent
import net.minestom.server.instance.DynamicChunk
import net.minestom.server.instance.InstanceContainer
import net.minestom.server.instance.LightingChunk
import net.minestom.server.instance.anvil.AnvilLoader
import net.minestom.server.registry.DynamicRegistry
import net.minestom.server.tag.Tag
import net.minestom.server.world.DimensionType
import org.slf4j.LoggerFactory
import java.nio.file.Path
import kotlin.io.path.absolutePathString

Expand Down Expand Up @@ -50,28 +51,13 @@ class AnvilFileMapProviderModule(val worldFolder: Path, private val dimensionTyp
}

companion object {

private val logger = LoggerFactory.getLogger(Companion::class.java)

val loadedMaps = mutableMapOf<Path, InstanceContainer>()

val MAP_NAME_TAG = Tag.String("anvil_file_map_name")

/**
* This method should be called when a SharedInstance is unregistered.
* It will check if the unregistrered instance is the last instance
* which depends on an Anvil map to be loaded, and if so, unloads the map.
*/
fun checkReleaseMap(instance: Instance) {
if (instance !is SharedInstance) return
val isLast = MinecraftServer.getInstanceManager().instances.none {
it !== instance && it is SharedInstance && it.instanceContainer === instance.instanceContainer
}
if (isLast) {
val key = loadedMaps.entries.find { it.value === instance.instanceContainer }?.key
loadedMaps.remove(key)
InstanceUtils.forceUnregisterInstance(instance.instanceContainer)
logger.info("Map file '${key?.fileName}' has been unloaded and its instance container has been unregistered.")
init {
// If an InstanceContainer is unregistered, remove it from `loadedMaps` so it can be garbage collected
MinecraftServer.getGlobalEventHandler().addListener(InstanceUnregisterEvent::class.java) { event ->
loadedMaps.entries.removeIf { (_, instance) -> instance == event.instance }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ object InstanceUtils {
* @return a CompletableFuture when all players are removed and the instance is unregistered.
*/
fun forceUnregisterInstance(instance: Instance): CompletableFuture<Void> {
val eventNode = EventNode.all("temp-${UUID.randomUUID()}")
val eventNode = EventNode.all("temp-vacate-${UUID.randomUUID()}")
eventNode.addListener(PlayerEvent::class.java) { event ->
event.player.kick(Component.text("This instance is shutting down."))
event.player.remove()
if (event.player.instance == instance) {
event.player.kick(Component.text("This instance is shutting down."))
event.player.remove()
}
}
MinecraftServer.getGlobalEventHandler().addChild(eventNode)
return vacateInstance(instance).thenRun {
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ okhttp = "4.10.0"
serialization = "1.5.0-RC"
tinylog = "2.6.2"
# Auto-generated GRPC/Protobuf messaging code
rpc = "18b740c038"
rpc = "dfd52fb7cc"
# Agones SDK and its necessary runtime dependencies
agones-kt = "0.1.2"
grpc = "1.50.2"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ object InitialInstanceRouter : Bootstrap(EnvType.PRODUCTION) {

private val INVALID_WORLD =
Component.text("Couldn't find which world to put you in! (Invalid world name)", NamedTextColor.RED)
private val INSTANCE_NOT_REGISTERED =
Component.text("Couldn't find which world to put you in! (Destination not ready)", NamedTextColor.RED)
private val HANDSHAKE_FAILED =
Component.text("Couldn't find which world to put you in! (Handshake failed)", NamedTextColor.RED)
private val DATA_LOAD_FAILED =
Expand Down Expand Up @@ -81,6 +83,12 @@ object InitialInstanceRouter : Bootstrap(EnvType.PRODUCTION) {
return@listenSuspend
}

if (!instance.isRegistered) {
logger.warn("Tried to send ${event.player.username} to an unregistered instance!")
event.player.kick(INSTANCE_NOT_REGISTERED)
return@listenSuspend
}

logger.info("Spawning player ${event.player.username} in game '${game.id}' and instance '${instance.uniqueId}'")
event.spawningInstance = instance

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import com.bluedragonmc.server.api.IncomingRPCHandler
import com.bluedragonmc.server.utils.miniMessage
import com.google.protobuf.Empty
import io.grpc.ServerBuilder
import net.kyori.adventure.sound.Sound
import net.kyori.adventure.sound.SoundStop
import net.minestom.server.MinecraftServer
import net.minestom.server.utils.NamespaceID
import org.slf4j.LoggerFactory
import java.util.*
import java.util.concurrent.TimeUnit
Expand Down Expand Up @@ -53,6 +56,7 @@ class IncomingRPCHandlerImpl(serverPort: Int) : IncomingRPCHandler {
gameState = CommonTypes.EnumGameState.ERROR
joinable = false
openSlots = 0
maxSlots = 0
}
return createInstanceResponse {
this.gameState = state
Expand All @@ -76,6 +80,26 @@ class IncomingRPCHandlerImpl(serverPort: Int) : IncomingRPCHandler {
return Empty.getDefaultInstance()
}

override suspend fun playSound(request: GsClient.PlaySoundRequest): Empty {
val target =
MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(UUID.fromString(request.playerUuid))
?: return Empty.getDefaultInstance()

target.playSound(Sound.sound(NamespaceID.from(request.soundId), Sound.Source.valueOf(request.category), request.volume, request.pitch))

return Empty.getDefaultInstance()
}

override suspend fun stopSound(request: GsClient.StopSoundRequest): Empty {
val target =
MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(UUID.fromString(request.playerUuid))
?: return Empty.getDefaultInstance()

target.stopSound(SoundStop.namedOnSource(NamespaceID.from(request.soundId), Sound.Source.valueOf(request.category)))

return Empty.getDefaultInstance()
}

override suspend fun getInstances(request: Empty): GsClient.GetInstancesResponse {
return getInstancesResponse {
Game.games.forEach { game ->
Expand All @@ -90,6 +114,12 @@ class IncomingRPCHandlerImpl(serverPort: Int) : IncomingRPCHandler {
}
}
}

override suspend fun endGame(request: GsClient.EndGameRequest): Empty {
val game = Game.findGame(request.gameId)
game?.endGame(request.queuePlayersForLobby)
return Empty.getDefaultInstance()
}
}

class PlayerHolderService : PlayerHolderGrpcKt.PlayerHolderCoroutineImplBase() {
Expand Down

0 comments on commit fcb599f

Please sign in to comment.