From 916a8bc9b09df66ccf7991f46017c67c23bbcafa Mon Sep 17 00:00:00 2001 From: FluxCapacitor <31071265+FluxCapacitor2@users.noreply.github.com> Date: Sun, 10 Mar 2024 15:26:53 -0400 Subject: [PATCH] Replace event logging system with post-game logs (#19) * Replace event logging system with post-game logs * Only log games that have started --- .../kotlin/com/bluedragonmc/server/Game.kt | 93 ++++++++++++++----- .../server/api/DatabaseConnection.kt | 4 +- .../server/model/DatabaseObjects.kt | 61 ++++++++---- .../module/database/StatisticsModule.kt | 36 ++++++- .../kotlin/com/bluedragonmc/server/Server.kt | 1 - .../server/bootstrap/ExceptionHandler.kt | 33 ------- .../server/bootstrap/IntegrationsInit.kt | 23 ----- .../bootstrap/prod/InitialInstanceRouter.kt | 10 -- .../server/impl/DatabaseConnectionImpl.kt | 14 +-- .../server/impl/OutgoingRPCHandlerImpl.kt | 16 ---- .../com/bluedragonmc/server/queue/IPCQueue.kt | 18 ---- 11 files changed, 153 insertions(+), 156 deletions(-) delete mode 100644 src/main/kotlin/com/bluedragonmc/server/bootstrap/ExceptionHandler.kt diff --git a/common/src/main/kotlin/com/bluedragonmc/server/Game.kt b/common/src/main/kotlin/com/bluedragonmc/server/Game.kt index e941878a..80f1cabf 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/Game.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/Game.kt @@ -9,15 +9,21 @@ import com.bluedragonmc.server.event.GameEvent import com.bluedragonmc.server.event.GameStartEvent import com.bluedragonmc.server.event.GameStateChangedEvent import com.bluedragonmc.server.event.PlayerLeaveGameEvent -import com.bluedragonmc.server.model.EventLog -import com.bluedragonmc.server.model.Severity +import com.bluedragonmc.server.model.GameDocument +import com.bluedragonmc.server.model.InstanceRecord +import com.bluedragonmc.server.model.PlayerRecord +import com.bluedragonmc.server.model.TeamRecord import com.bluedragonmc.server.module.GameModule +import com.bluedragonmc.server.module.database.StatisticsModule import com.bluedragonmc.server.module.instance.InstanceModule import com.bluedragonmc.server.module.minigame.SpawnpointModule +import com.bluedragonmc.server.module.minigame.TeamModule +import com.bluedragonmc.server.module.minigame.WinModule import com.bluedragonmc.server.service.Database import com.bluedragonmc.server.service.Messaging import com.bluedragonmc.server.utils.GameState import com.bluedragonmc.server.utils.InstanceUtils +import com.bluedragonmc.server.utils.toPlainText import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import net.kyori.adventure.text.Component @@ -80,6 +86,9 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n 'a' + Random.nextInt(0, 26) }.joinToString("") + private lateinit var startTime: Date + private lateinit var winningTeam: TeamModule.Team + open val maxPlayers = 8 var state: GameState = GameState.SERVER_STARTING @@ -124,14 +133,10 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n } } onGameStart { - Database.IO.launch { - Database.connection.logEvent( - EventLog("game_started", Severity.DEBUG) - .withProperty("game_id", id) - .withProperty("players", players.map { it.uuid.toString() }) - .withProperty("modules", modules.map { it.toString() }) - ) - } + startTime = Date() + } + handleEvent { event -> + winningTeam = event.winningTeam } } @@ -224,11 +229,65 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n } open fun endGame(queueAllPlayers: Boolean = true) { + + // Log some information about the game in the database + + val statHistory = getModuleOrNull()?.getHistory() + val teams = getModuleOrNull()?.teams?.map { team -> + TeamRecord( + name = team.name.toPlainText(), + players = team.players.map { player -> + PlayerRecord( + uuid = player.uuid, + username = player.username + ) + } + ) + } + val winningTeamRecord = if (::winningTeam.isInitialized) { + TeamRecord( + name = winningTeam.name.toPlainText(), + players = winningTeam.players.map { player -> + PlayerRecord( + uuid = player.uuid, + username = player.username + ) + }) + } else null + + val instanceRecords = getOwnedInstances().map { instance -> + InstanceRecord( + type = instance::class.jvmName, + uuid = instance.uniqueId + ) + } + + 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 + ) + ) + } + } + state = GameState.ENDING games.remove(this) + // the NotifyInstanceRemovedMessage is published when the MessagingModule is unregistered while (modules.isNotEmpty()) unregister(modules.first()) - modules.forEach { it.deinitialize() } + if (queueAllPlayers) { players.forEach { it.sendMessage(Component.translatable("game.status.ending", NamedTextColor.GREEN)) @@ -242,12 +301,14 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n }) } } + MinecraftServer.getSchedulerManager().buildTask { MinecraftServer.getInstanceManager().instances.filter { this.ownsInstance(it) }.forEach { instance -> logger.info("Forcefully unregistering instance ${instance.uniqueId}...") InstanceUtils.forceUnregisterInstance(instance).join() } }.executionType(ExecutionType.ASYNC).delay(Duration.ofSeconds(10)) + players.clear() } @@ -287,16 +348,6 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n // Allow the game to start receiving events MinecraftServer.getGlobalEventHandler().addChild(eventNode) - - Database.IO.launch { - Database.connection.logEvent( - EventLog("game_created", Severity.DEBUG) - .withProperty("game_id", id) - .withProperty("game_type", name) - .withProperty("map_name", mapName) - .withProperty("mode", mode) - ) - } } protected abstract fun initialize() diff --git a/common/src/main/kotlin/com/bluedragonmc/server/api/DatabaseConnection.kt b/common/src/main/kotlin/com/bluedragonmc/server/api/DatabaseConnection.kt index 57a81f63..c37efece 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/api/DatabaseConnection.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/api/DatabaseConnection.kt @@ -1,7 +1,7 @@ package com.bluedragonmc.server.api import com.bluedragonmc.server.CustomPlayer -import com.bluedragonmc.server.model.EventLog +import com.bluedragonmc.server.model.GameDocument import com.bluedragonmc.server.model.PlayerDocument import net.minestom.server.entity.Player import java.util.* @@ -23,5 +23,5 @@ interface DatabaseConnection { suspend fun updatePlayer(playerUuid: String, field: KMutableProperty, value: T) - suspend fun logEvent(event: EventLog) + suspend fun logGame(game: GameDocument) } diff --git a/common/src/main/kotlin/com/bluedragonmc/server/model/DatabaseObjects.kt b/common/src/main/kotlin/com/bluedragonmc/server/model/DatabaseObjects.kt index 14200f2a..af0e6b88 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/model/DatabaseObjects.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/model/DatabaseObjects.kt @@ -2,10 +2,11 @@ package com.bluedragonmc.server.model -import com.bluedragonmc.server.api.Environment import com.bluedragonmc.server.service.Database -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.* +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import java.time.Duration import java.time.Instant import java.util.* @@ -79,23 +80,43 @@ data class Achievement( @Serializable(with = DateSerializer::class) val earnedAt: Date, ) -enum class Severity { - TRACE, DEBUG, INFO, WARN, ERROR, FATAL -} +@Serializable +data class PlayerRecord( + @SerialName("_id") @Serializable(with = UUIDSerializer::class) val uuid: UUID, + val username: String, +) -data class EventLog( - val type: String, - val severity: Severity, -) { - companion object { - private val serverName = runBlocking { Environment.getServerName() } - } +@Serializable +data class TeamRecord( + @SerialName("_id") val name: String, + val players: List, +) - val date: Long = System.currentTimeMillis() - val node: String = serverName - val properties = mutableMapOf() +@Serializable +data class StatisticRecord( + val key: String, + val player: PlayerRecord, + val oldValue: Double?, + val newValue: Double, +) - fun withProperty(name: String, value: Any?) = apply { - properties[name] = value - } -} \ No newline at end of file +@Serializable +data class InstanceRecord( + @SerialName("_id") @Serializable(with = UUIDSerializer::class) val uuid: UUID, + val type: String, +) + +@Serializable +data class GameDocument( + val gameId: String, + val serverId: String, + val gameType: String, + val mode: String?, + val mapName: String, + @EncodeDefault val teams: List? = listOf(), + val winningTeam: TeamRecord?, + @EncodeDefault val statistics: List? = listOf(), + @Serializable(with = DateSerializer::class) val startTime: Date?, + @Serializable(with = DateSerializer::class) val endTime: Date, + val instances: List, +) \ No newline at end of file diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/database/StatisticsModule.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/database/StatisticsModule.kt index bda744f1..27cc443a 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/module/database/StatisticsModule.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/database/StatisticsModule.kt @@ -5,6 +5,8 @@ import com.bluedragonmc.server.Game import com.bluedragonmc.server.event.DataLoadedEvent import com.bluedragonmc.server.event.PlayerLeaveGameEvent import com.bluedragonmc.server.model.PlayerDocument +import com.bluedragonmc.server.model.PlayerRecord +import com.bluedragonmc.server.model.StatisticRecord import com.bluedragonmc.server.module.GameModule import com.bluedragonmc.server.module.database.StatisticsModule.StatisticRecorder import com.bluedragonmc.server.module.minigame.WinModule @@ -32,7 +34,7 @@ import java.util.function.Predicate * It is recommended, however not required, to use a * [StatisticRecorder] to record statistics */ -class StatisticsModule(private vararg val recorders : StatisticRecorder) : GameModule() { +class StatisticsModule(private vararg val recorders: StatisticRecorder) : GameModule() { companion object { // Caches should be static to reduce the number of DB queries @@ -103,6 +105,25 @@ class StatisticsModule(private vararg val recorders : StatisticRecorder) : GameM } } + override fun deinitialize() { + history.clear() + } + + private val history = mutableMapOf, Pair>() + + fun getHistory(): List { + return history.map { (playerAndKey, values) -> + val (player, key) = playerAndKey + val (oldValue, newValue) = values + StatisticRecord( + key = key, + player = PlayerRecord(uuid = player.uuid, username = player.username), + oldValue = oldValue, + newValue = newValue + ) + } + } + /** * Records a statistic for the [player] using the [key] and provided [value]. * The operation may be delayed as statistic updates are batched. @@ -110,8 +131,16 @@ class StatisticsModule(private vararg val recorders : StatisticRecorder) : GameM fun recordStatistic(player: Player, key: String, value: Double) { player as CustomPlayer + // Record the change in the game's statistic history for logging purposes + if (history.containsKey(player to key)) { + history[player to key] = history[player to key]?.first to value + } else { + history[player to key] = player.data.statistics[key] to value + } + // Update the local player data to reflect the change player.data.statistics[key] = value + // Queue a database update operation necessaryUpdates.getOrPut(player) { mutableSetOf() }.add(key) } @@ -208,7 +237,10 @@ class StatisticsModule(private vararg val recorders : StatisticRecorder) : GameM return documents.associateWith { it.statistics[key]!! } } - class EventStatisticRecorder(private val eventType: Class, val handler: suspend StatisticsModule.(Game, T) -> Unit) : StatisticRecorder() { + class EventStatisticRecorder( + private val eventType: Class, + val handler: suspend StatisticsModule.(Game, T) -> Unit, + ) : StatisticRecorder() { override fun subscribe(module: StatisticsModule, game: Game, eventNode: EventNode) { eventNode.addListener(eventType) { event -> Database.IO.launch { handler(module, game, event) } diff --git a/src/main/kotlin/com/bluedragonmc/server/Server.kt b/src/main/kotlin/com/bluedragonmc/server/Server.kt index 089dd945..d81ce239 100644 --- a/src/main/kotlin/com/bluedragonmc/server/Server.kt +++ b/src/main/kotlin/com/bluedragonmc/server/Server.kt @@ -48,7 +48,6 @@ fun start() { Commands, CustomPlayerProvider, DevInstanceRouter, - ExceptionHandler, GlobalBlockHandlers, GlobalChatFormat, GlobalPlayerNameFormat, diff --git a/src/main/kotlin/com/bluedragonmc/server/bootstrap/ExceptionHandler.kt b/src/main/kotlin/com/bluedragonmc/server/bootstrap/ExceptionHandler.kt deleted file mode 100644 index 4c66a81e..00000000 --- a/src/main/kotlin/com/bluedragonmc/server/bootstrap/ExceptionHandler.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.bluedragonmc.server.bootstrap - -import com.bluedragonmc.server.model.EventLog -import com.bluedragonmc.server.model.Severity -import com.bluedragonmc.server.service.Database -import com.google.common.util.concurrent.RateLimiter -import kotlinx.coroutines.launch -import net.minestom.server.MinecraftServer -import net.minestom.server.event.Event -import net.minestom.server.event.EventNode -import java.util.concurrent.TimeUnit - -object ExceptionHandler : Bootstrap() { - - private val rateLimiter = RateLimiter.create(2.0) - - override fun hook(eventNode: EventNode) { - MinecraftServer.getExceptionManager().setExceptionHandler { throwable -> - throwable.printStackTrace() - Database.IO.launch { - if (rateLimiter.tryAcquire(3, TimeUnit.SECONDS)) { - Database.connection.logEvent( - EventLog("minestom_error", Severity.ERROR) - .withProperty("exception", throwable::class.qualifiedName) - .withProperty("message", throwable.message) - .withProperty("stack_trace", throwable.stackTraceToString()) - ) - } - } - } - } - -} diff --git a/src/main/kotlin/com/bluedragonmc/server/bootstrap/IntegrationsInit.kt b/src/main/kotlin/com/bluedragonmc/server/bootstrap/IntegrationsInit.kt index 1c2a75d5..2f4ccb2a 100644 --- a/src/main/kotlin/com/bluedragonmc/server/bootstrap/IntegrationsInit.kt +++ b/src/main/kotlin/com/bluedragonmc/server/bootstrap/IntegrationsInit.kt @@ -7,29 +7,16 @@ import com.bluedragonmc.server.impl.DatabaseConnectionImpl import com.bluedragonmc.server.impl.IncomingRPCHandlerImpl import com.bluedragonmc.server.impl.OutgoingRPCHandlerImpl import com.bluedragonmc.server.impl.PermissionManagerImpl -import com.bluedragonmc.server.model.EventLog -import com.bluedragonmc.server.model.Severity import com.bluedragonmc.server.service.Database import com.bluedragonmc.server.service.Messaging import com.bluedragonmc.server.service.Permissions -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import net.minestom.server.MinecraftServer import net.minestom.server.event.Event import net.minestom.server.event.EventNode import java.net.InetAddress object IntegrationsInit : Bootstrap() { override fun hook(eventNode: EventNode) { - - MinecraftServer.getSchedulerManager().buildShutdownTask { - runBlocking { - Database.connection.logEvent( - EventLog("game_server_shutdown", Severity.DEBUG) - ) - } - } - Database.initialize(DatabaseConnectionImpl("mongodb://${Environment.mongoHostname}")) Permissions.initialize(PermissionManagerImpl()) @@ -44,16 +31,6 @@ object IntegrationsInit : Bootstrap() { runBlocking { Messaging.outgoing.initGameServer(Environment.getServerName()) } - Database.IO.launch { - Database.connection.logEvent( - EventLog("game_server_started", Severity.DEBUG) - .withProperty("is_dev", Environment.isDev.toString()) - .withProperty("mongo_hostname", Environment.mongoHostname) - .withProperty("puffin_hostname", Environment.puffinHostname) - .withProperty("luckperms_hostname", Environment.current.luckPermsHostname) - .withProperty("game_classes", Environment.current.gameClasses) - ) - } } } } diff --git a/src/main/kotlin/com/bluedragonmc/server/bootstrap/prod/InitialInstanceRouter.kt b/src/main/kotlin/com/bluedragonmc/server/bootstrap/prod/InitialInstanceRouter.kt index e06a7435..95617927 100644 --- a/src/main/kotlin/com/bluedragonmc/server/bootstrap/prod/InitialInstanceRouter.kt +++ b/src/main/kotlin/com/bluedragonmc/server/bootstrap/prod/InitialInstanceRouter.kt @@ -4,8 +4,6 @@ import com.bluedragonmc.server.CustomPlayer import com.bluedragonmc.server.Game import com.bluedragonmc.server.api.Environment import com.bluedragonmc.server.bootstrap.Bootstrap -import com.bluedragonmc.server.model.EventLog -import com.bluedragonmc.server.model.Severity import com.bluedragonmc.server.module.instance.InstanceModule import com.bluedragonmc.server.module.minigame.SpawnpointModule import com.bluedragonmc.server.service.Database @@ -96,14 +94,6 @@ object InitialInstanceRouter : Bootstrap(EnvType.PRODUCTION) { Messaging.IO.launch { Messaging.outgoing.playerTransfer(event.player, game.id) } - Database.IO.launch { - Database.connection.logEvent( - EventLog("player_login", Severity.DEBUG) - .withProperty("player_uuid", event.player.uuid.toString()) - .withProperty("initial_game_id", game.id) - .withProperty("given_destination", destination) - ) - } } } } \ No newline at end of file diff --git a/src/main/kotlin/com/bluedragonmc/server/impl/DatabaseConnectionImpl.kt b/src/main/kotlin/com/bluedragonmc/server/impl/DatabaseConnectionImpl.kt index 44690cb0..856ec2dd 100644 --- a/src/main/kotlin/com/bluedragonmc/server/impl/DatabaseConnectionImpl.kt +++ b/src/main/kotlin/com/bluedragonmc/server/impl/DatabaseConnectionImpl.kt @@ -4,7 +4,7 @@ import com.bluedragonmc.server.CustomPlayer import com.bluedragonmc.server.api.DatabaseConnection import com.bluedragonmc.server.api.Environment import com.bluedragonmc.server.event.DataLoadedEvent -import com.bluedragonmc.server.model.EventLog +import com.bluedragonmc.server.model.GameDocument import com.bluedragonmc.server.model.PlayerDocument import com.bluedragonmc.server.model.Punishment import com.mongodb.ConnectionString @@ -17,7 +17,6 @@ import net.kyori.adventure.text.format.NamedTextColor import net.minestom.server.MinecraftServer import net.minestom.server.entity.Player import net.minestom.server.network.packet.server.login.LoginDisconnectPacket -import org.bson.Document import org.litote.kmongo.coroutine.CoroutineClient import org.litote.kmongo.coroutine.CoroutineCollection import org.litote.kmongo.coroutine.CoroutineDatabase @@ -59,7 +58,7 @@ internal class DatabaseConnectionImpl(connectionString: String) : DatabaseConnec } private fun getPlayersCollection(): CoroutineCollection = database.getCollection("players") - private fun getEventsCollection(): CoroutineCollection = database.getCollection("events") + private fun getGamesCollection(): CoroutineCollection = database.getCollection("games") override suspend fun getPlayerDocument(username: String): PlayerDocument? { MinecraftServer.getConnectionManager().getOnlinePlayerByUsername(username)?.let { @@ -148,12 +147,7 @@ internal class DatabaseConnectionImpl(connectionString: String) : DatabaseConnec getPlayersCollection().updateOneById(playerUuid, setValue(field, value)) } - override suspend fun logEvent(event: EventLog) { - val doc = Document() - doc["type"] = event.type - doc["node"] = event.node - doc["date"] = event.date - doc["properties"] = event.properties - getEventsCollection().insertOne(doc) + override suspend fun logGame(game: GameDocument) { + getGamesCollection().insertOne(game) } } \ No newline at end of file diff --git a/src/main/kotlin/com/bluedragonmc/server/impl/OutgoingRPCHandlerImpl.kt b/src/main/kotlin/com/bluedragonmc/server/impl/OutgoingRPCHandlerImpl.kt index 428addd6..d39484d9 100644 --- a/src/main/kotlin/com/bluedragonmc/server/impl/OutgoingRPCHandlerImpl.kt +++ b/src/main/kotlin/com/bluedragonmc/server/impl/OutgoingRPCHandlerImpl.kt @@ -5,12 +5,9 @@ import com.bluedragonmc.api.grpc.Queue import com.bluedragonmc.server.Game import com.bluedragonmc.server.api.OutgoingRPCHandler import com.bluedragonmc.server.event.GameStateChangedEvent -import com.bluedragonmc.server.model.EventLog -import com.bluedragonmc.server.model.Severity import com.bluedragonmc.server.module.DependsOn import com.bluedragonmc.server.module.GameModule import com.bluedragonmc.server.module.instance.InstanceModule -import com.bluedragonmc.server.service.Database import com.bluedragonmc.server.service.Messaging import com.bluedragonmc.server.utils.listen import com.bluedragonmc.server.utils.listenSuspend @@ -83,24 +80,11 @@ class OutgoingRPCHandlerImpl(serverAddress: String) : OutgoingRPCHandler { Messaging.outgoing.updateGameState(parent.id, parent.rpcGameState) } } - Database.IO.launch { - Database.connection.logEvent( - EventLog("player_logout", Severity.DEBUG) - .withProperty("player_uuid", event.player.uuid.toString()) - ) - } } } override fun deinitialize(): Unit = runBlocking { Messaging.outgoing.notifyInstanceRemoved(parent.id) - - Database.IO.launch { - Database.connection.logEvent( - EventLog("game_removed", Severity.DEBUG) - .withProperty("game_id", parent.id) - ) - } } } diff --git a/src/main/kotlin/com/bluedragonmc/server/queue/IPCQueue.kt b/src/main/kotlin/com/bluedragonmc/server/queue/IPCQueue.kt index f5de6ae2..0431a37e 100644 --- a/src/main/kotlin/com/bluedragonmc/server/queue/IPCQueue.kt +++ b/src/main/kotlin/com/bluedragonmc/server/queue/IPCQueue.kt @@ -7,8 +7,6 @@ import com.bluedragonmc.server.Game import com.bluedragonmc.server.api.Environment import com.bluedragonmc.server.api.Queue import com.bluedragonmc.server.lobby -import com.bluedragonmc.server.model.EventLog -import com.bluedragonmc.server.model.Severity import com.bluedragonmc.server.module.instance.InstanceModule import com.bluedragonmc.server.service.Database import com.bluedragonmc.server.service.Messaging @@ -38,13 +36,6 @@ object IPCQueue : Queue() { Messaging.outgoing.removeFromQueue(player) } else { Messaging.outgoing.addToQueue(player, gameType) - Database.connection.logEvent( - EventLog("player_queued", Severity.DEBUG) - .withProperty("player_uuid", player.uuid.toString()) - .withProperty("game_type", gameType.name) - .withProperty("map_name", gameType.mapName) - .withProperty("mode", gameType.mode) - ) } } } @@ -103,14 +94,5 @@ object IPCQueue : Queue() { logger.info("Sending player ${player.username} to game '$gameId' and instance '${instance.uniqueId}'. (current instance: ${player.instance?.uniqueId})") game.addPlayer(player) - - Database.IO.launch { - Database.connection.logEvent( - EventLog("player_sent", Severity.DEBUG) - .withProperty("player_uuid", player.uuid.toString()) - .withProperty("instance_uuid", instance.uniqueId.toString()) - .withProperty("game_id", gameId) - ) - } } } \ No newline at end of file