diff --git a/build.gradle.kts b/build.gradle.kts index 1e060684..89286353 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(libs.bundles.configurate) // Configurate for game configuration implementation(libs.bundles.messaging) // Messaging implementation(libs.okhttp) + implementation(libs.polar) implementation(project(":common")) } diff --git a/common/src/main/kotlin/com/bluedragonmc/server/Game.kt b/common/src/main/kotlin/com/bluedragonmc/server/Game.kt index a94f6f22..29b74840 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/Game.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/Game.kt @@ -1,11 +1,11 @@ package com.bluedragonmc.server import com.bluedragonmc.api.grpc.CommonTypes -import com.bluedragonmc.api.grpc.CommonTypes.GameType.GameTypeFieldSelector import com.bluedragonmc.api.grpc.gameState import com.bluedragonmc.api.grpc.gameType import com.bluedragonmc.server.api.Environment import com.bluedragonmc.server.event.* +import com.bluedragonmc.server.game.GameData import com.bluedragonmc.server.model.GameDocument import com.bluedragonmc.server.model.InstanceRecord import com.bluedragonmc.server.model.PlayerRecord @@ -22,7 +22,6 @@ 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 import net.kyori.adventure.text.format.NamedTextColor import net.minestom.server.MinecraftServer @@ -51,18 +50,9 @@ import java.util.function.Predicate import kotlin.random.Random import kotlin.reflect.jvm.jvmName -abstract class Game(val name: String, val mapName: String, val mode: String? = null) : ModuleHolder(), +abstract class Game(val data: GameData) : ModuleHolder(), PacketGroupingAudience { - val gameType: CommonTypes.GameType - get() = gameType { - name = this@Game.name - mapName = this@Game.mapName - if (this@Game.mode != null) { - mode = this@Game.mode - } - } - val rpcGameState: CommonTypes.GameState get() = gameState { gameState = state.mapToRpcState() @@ -80,7 +70,7 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n * A random, 4-character identifier unique to this game. */ val id = (0 until 4).map { - 'a' + Random.nextInt(0, 26) + 'a' + Random.Default.nextInt(0, 26) }.joinToString("") private lateinit var startTime: Date @@ -95,7 +85,7 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n } } - protected val eventNode = EventNode.event("$name-$mapName-$mode", EventFilter.ALL) { event -> + protected val eventNode = EventNode.event("$id-$data", EventFilter.ALL) { event -> try { return@event when (event) { is InstanceEvent -> ownsInstance(event.instance ?: return@event false) @@ -135,7 +125,9 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n startTime = Date() } handleEvent { event -> - winningTeam = TeamRecord(event.winningTeamName.toPlainText(), event.winningTeamPlayers.map { PlayerRecord(it.uuid, it.username) }) + winningTeam = TeamRecord( + event.winningTeamName.toPlainText(), + event.winningTeamPlayers.map { PlayerRecord(it.uuid, it.username) }) } } @@ -206,7 +198,6 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n ) Environment.queue.queue(player, gameType { name = Environment.defaultGameName - selectors += GameTypeFieldSelector.GAME_NAME }) return AsyncUtils.empty() } @@ -263,9 +254,9 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n GameDocument( gameId = id, serverId = Environment.getServerName(), - gameType = name, - mapName = mapName, - mode = mode, + gameType = data.name, + mapName = data.mapSource.id, + mode = data.mode, statistics = statHistory, teams = teams, winningTeam = winningTeamRecord, @@ -290,17 +281,13 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n while (modules.isNotEmpty()) unregister(modules.first()) if (queueAllPlayers) { - players.forEach { - it.sendMessage(Component.translatable("game.status.ending", NamedTextColor.GREEN)) - Environment.queue.queue(it, gameType { - name = this@Game.name - if (this@Game.mode != null) { - mode = this@Game.mode - selectors += GameTypeFieldSelector.GAME_MODE - } - selectors += GameTypeFieldSelector.GAME_NAME - }) + val gameType = gameType { + name = data.name + if (data.mode != null) { + mode = data.mode + } } + Environment.queue.bulkEnqueue(players.map { it to gameType }) } MinecraftServer.getSchedulerManager().buildTask { @@ -318,19 +305,8 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n open fun isInactive(): Boolean { // Games with players are always considered active if (players.isNotEmpty()) return false - // Games without players are always inactive after 4 hours - if (System.currentTimeMillis() - creationTime >= 1_000 * 60 * 60 * 4) return true - // Games without players are always active in the first 30 minutes after being created - if (System.currentTimeMillis() - creationTime <= 1_000 * 60 * 30 && !playerHasJoined) return false - - try { - return runBlocking { - Messaging.outgoing.checkRemoveInstance(id) - } - } catch (e: Throwable) { - e.printStackTrace() - return true - } + // Games without players are always inactive after 5 minutes + return System.currentTimeMillis() - creationTime >= 1_000 * 60 * 5 } fun init() { @@ -358,7 +334,7 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n override fun toString(): String { val modules = modules.joinToString { it::class.simpleName ?: it::class.jvmName } val players = players.joinToString { it.username } - return "Game(id='$id', name='$name', mapName='$mapName', mode='$mode', modules=$modules, players=$players, maxPlayers=$maxPlayers, isJoinable=$isJoinable, state=$state)" + return "Game(id='$id', data=$data, modules=$modules, players=$players, maxPlayers=$maxPlayers, isJoinable=$isJoinable, state=$state)" } companion object { @@ -402,7 +378,7 @@ abstract class Game(val name: String, val mapName: String, val mode: String? = n games.forEach { game -> if (game.isInactive()) { - logger.info("Ending inactive game ${game.id} (${game.name}/${game.mapName}/${game.mode})") + logger.info("Ending inactive game ${game.id} (${game.data})") game.endGame(false) } game._players.removeIf { player -> !player.isOnline } diff --git a/common/src/main/kotlin/com/bluedragonmc/server/api/OutgoingRPCHandler.kt b/common/src/main/kotlin/com/bluedragonmc/server/api/OutgoingRPCHandler.kt index e3f777b5..bdb70803 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/api/OutgoingRPCHandler.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/api/OutgoingRPCHandler.kt @@ -26,15 +26,19 @@ interface OutgoingRPCHandler { suspend fun initGame(id: String, gameType: GameType, gameState: GameState) suspend fun updateGameState(id: String, gameState: GameState) suspend fun notifyInstanceRemoved(gameId: String) - suspend fun checkRemoveInstance(gameId: String): Boolean // Player tracking suspend fun recordInstanceChange(player: Player, newGame: String) suspend fun playerTransfer(player: Player, newGame: String?) suspend fun queryPlayer(username: String? = null, uuid: UUID? = null): QueryPlayerResponse + // Maps + suspend fun getAvailableMaps(gameName: String?, gameMode: String?, whitelist: List?): com.bluedragonmc.api.grpc.Map.MapList + suspend fun updateMapConfig(mapId: String, configJson: String) + // Queue suspend fun addToQueue(player: Player, gameType: GameType) + suspend fun bulkAddToQueue(messages: List>) suspend fun removeFromQueue(player: Player) suspend fun getDestination(player: UUID): String? diff --git a/common/src/main/kotlin/com/bluedragonmc/server/api/OutgoingRPCHandlerStub.kt b/common/src/main/kotlin/com/bluedragonmc/server/api/OutgoingRPCHandlerStub.kt index 807b65ca..2c8c6b55 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/api/OutgoingRPCHandlerStub.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/api/OutgoingRPCHandlerStub.kt @@ -1,11 +1,15 @@ package com.bluedragonmc.server.api import com.bluedragonmc.api.grpc.* +import com.bluedragonmc.api.grpc.Map import com.bluedragonmc.server.Game import net.kyori.adventure.text.Component import net.minestom.server.command.CommandSender import net.minestom.server.entity.Player +import java.io.File +import java.nio.file.Paths import java.util.* +import kotlin.io.path.name /** * Stub - no functionality. Used in development and testing environments. @@ -37,10 +41,6 @@ class OutgoingRPCHandlerStub : OutgoingRPCHandler { } - override suspend fun checkRemoveInstance(gameId: String): Boolean { - return true - } - override suspend fun recordInstanceChange(player: Player, newGame: String) { } @@ -53,10 +53,32 @@ class OutgoingRPCHandlerStub : OutgoingRPCHandler { return PlayerTrackerOuterClass.QueryPlayerResponse.getDefaultInstance() } + override suspend fun getAvailableMaps(gameName: String?, gameMode: String?, whitelist: List?): Map.MapList { + val mapDefs = File("worlds").listFiles() + .flatMap { it.listFiles().map { file -> file.absolutePath } } + .map { mapFolderPath -> + CommonTypes.MapSource.newBuilder() + .setMapId(Paths.get(mapFolderPath).name) + .setMapConfig(File(mapFolderPath, "config.yml").readText()) + .setMapFormat(CommonTypes.MapFormat.ANVIL) + .setMapUrl("file://$mapFolderPath") + .build() + } + return com.bluedragonmc.api.grpc.Map.MapList.newBuilder().addAllMaps(mapDefs).build() + } + + override suspend fun updateMapConfig(mapId: String, configJson: String) { + + } + override suspend fun addToQueue(player: Player, gameType: CommonTypes.GameType) { } + override suspend fun bulkAddToQueue(messages: List>) { + + } + override suspend fun removeFromQueue(player: Player) { } diff --git a/common/src/main/kotlin/com/bluedragonmc/server/api/Queue.kt b/common/src/main/kotlin/com/bluedragonmc/server/api/Queue.kt index 77778756..67b95ad4 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/api/Queue.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/api/Queue.kt @@ -5,15 +5,12 @@ import com.bluedragonmc.api.grpc.GsClient import com.bluedragonmc.api.grpc.PlayerHolderOuterClass.SendPlayerRequest import com.bluedragonmc.server.Game import net.minestom.server.entity.Player -import java.io.File abstract class Queue { abstract fun start() abstract fun queue(player: Player, gameType: CommonTypes.GameType) - - abstract fun getMaps(gameType: String): Array? - abstract fun randomMap(gameType: String): String? + abstract fun bulkEnqueue(requests: List>) open fun createInstance(request: GsClient.CreateInstanceRequest): Game? { throw NotImplementedError("Creating instances not implemented") diff --git a/common/src/main/kotlin/com/bluedragonmc/server/command/BlueDragonCommand.kt b/common/src/main/kotlin/com/bluedragonmc/server/command/BlueDragonCommand.kt index a96b5e3e..13dece86 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/command/BlueDragonCommand.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/command/BlueDragonCommand.kt @@ -25,7 +25,6 @@ import net.minestom.server.command.builder.arguments.Argument import net.minestom.server.coordinate.Point import net.minestom.server.entity.Player import net.minestom.server.utils.entity.EntityFinder -import java.util.function.Predicate /** * A basic command class that is extended by BlueDragon commands. @@ -144,21 +143,6 @@ open class BlueDragonCommand( private fun constructSubcommand(name: String, block: BlueDragonCommand.() -> Unit) = BlueDragonCommand(name, emptyArray(), permission, block) - /** - * Only allow senders which pass the [scopePredicate] - * to execute the command. Primarily used to limit commands - * on a per-game basis. - */ - fun scopeTo(scopePredicate: Predicate) { - conditions.add { - if (!scopePredicate.test(sender)) { - sender.sendMessage(Component.text("You can't use that command here!", NamedTextColor.RED)) - return@add false - } - return@add true - } - } - fun syntax(vararg args: Argument<*>, block: CommandCtx.() -> Unit) = Syntax(this, args.toList(), block) fun suspendSyntax(vararg args: Argument<*>, block: suspend CommandCtx.() -> Unit) = BlockingSyntax(this, args.toList(), block) diff --git a/common/src/main/kotlin/com/bluedragonmc/server/game/GameData.kt b/common/src/main/kotlin/com/bluedragonmc/server/game/GameData.kt new file mode 100644 index 00000000..d70dff19 --- /dev/null +++ b/common/src/main/kotlin/com/bluedragonmc/server/game/GameData.kt @@ -0,0 +1,20 @@ +package com.bluedragonmc.server.game + +import com.bluedragonmc.api.grpc.CommonTypes +import com.bluedragonmc.api.grpc.gameType +import com.bluedragonmc.server.service.Maps + +data class GameData( + val name: String, + val mapSource: Maps.MapSource, + val mode: String? = null, +) { + val gameType: CommonTypes.GameType + get() = gameType { + name = this@GameData.name + mapId = mapSource.id + if (this@GameData.mode != null) { + mode = this@GameData.mode + } + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/ScopedCommandModule.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/ScopedCommandModule.kt new file mode 100644 index 00000000..714f2da5 --- /dev/null +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/ScopedCommandModule.kt @@ -0,0 +1,113 @@ +package com.bluedragonmc.server.module + +import com.bluedragonmc.server.Game +import com.bluedragonmc.server.event.PlayerJoinGameEvent +import com.bluedragonmc.server.event.PlayerLeaveGameEvent +import net.minestom.server.MinecraftServer +import net.minestom.server.command.builder.Command +import net.minestom.server.entity.Player +import net.minestom.server.event.Event +import net.minestom.server.event.EventNode +import java.lang.ref.WeakReference + +/** + * This module allows a command to be registered and managed within the lifecycle of its parent [Game]. + * + * Note that Minestom requires command names to be **globally unique**. Registering two commands with the same names + * will use the implementation of the first one to be registered. + */ +class ScopedCommandModule : GameModule() { + private lateinit var parent: Game + + override fun initialize( + parent: Game, + eventNode: EventNode + ) { + this.parent = parent + eventNode.addListener(PlayerJoinGameEvent::class.java) { event -> + event.player.refreshCommands() + } + eventNode.addListener(PlayerLeaveGameEvent::class.java) { event -> + event.player.refreshCommands() + } + } + + override fun deinitialize() { + registeredCommands.forEach { (_, cmd) -> + cmd.games.removeAll { it.get() == parent } + } + + unregisterUnusedCommands() + + parent.players.forEach(Player::refreshCommands) + } + + /** + * Removes commands from the Minestom command manager that aren't currently registered by any games + */ + private fun unregisterUnusedCommands() { + registeredCommands.entries.removeAll { (_, cmd) -> + val isUnused = cmd.games.all { it.get() == null } + if (isUnused) { + MinecraftServer.getCommandManager().unregister(cmd.command) + } + isUnused + } + } + + fun registerCommand(commandGenerator: () -> Command) { + val command = commandGenerator() + val originalCondition = command.condition + + var registration = registeredCommands[command.name] + if (registration == null && MinecraftServer.getCommandManager().getCommand(command.name) != null) { + error("Command was registered outside of ScopedCommandModule") + } + + if (registration == null) { + val newValue = ScopedCommand(command, mutableListOf()) + registeredCommands[command.name] = newValue + registration = newValue + command.condition = condition@{ commandSender, name -> + val player = commandSender as? Player ?: return@condition false + if (originalCondition?.canUse(player, name) == false) { + return@condition false + } + val currentGame = Game.findGame(player) ?: return@condition false + if (!currentGame.players.contains(player)) return@condition false // This happens when currentGame owns the instance that the player is in, but they haven't been added to the player list. In this case, they shouldn't see the command. + return@condition registration.games.any { it.get() == currentGame } + } + MinecraftServer.getCommandManager().register(command) + + parent.players.forEach(Player::refreshCommands) + } + + if (registration.games.none { it.get() == parent }) { + registration.games.add(WeakReference(parent)) + } + } + + fun unregisterCommand(command: Command) { + val registration = registeredCommands[command.name] ?: return + + registration.games.removeAll { it.get() == parent } + + unregisterUnusedCommands() + parent.players.forEach(Player::refreshCommands) + } + + companion object { + /** + * Minestom command registrations are global; two commands with the same name can't both be registered. + * We work around this by remembering which command names have already been registered. + * + * Then, if a game attempts to register an already-registered command, we just allow that game's players + * to see the existing command. + * + * This relies on the assumption that commands with the same name will always behave the same. + */ + private val registeredCommands: MutableMap = mutableMapOf() + } + + private data class ScopedCommand(val command: Command, val games: MutableList>) +} diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/config/ConfigModule.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/config/ConfigModule.kt index a5d2d4bc..76fb909a 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/module/config/ConfigModule.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/config/ConfigModule.kt @@ -2,13 +2,13 @@ package com.bluedragonmc.server.module.config import com.bluedragonmc.server.Game import com.bluedragonmc.server.module.GameModule -import com.bluedragonmc.server.module.SoftDependsOn import com.bluedragonmc.server.module.config.serializer.* -import com.bluedragonmc.server.module.map.AnvilFileMapProviderModule import com.bluedragonmc.server.module.minigame.KitsModule +import com.bluedragonmc.server.service.Maps +import kotlinx.coroutines.runBlocking import net.kyori.adventure.text.Component import net.minestom.server.color.Color -import net.minestom.server.coordinate.Pos +import net.minestom.server.coordinate.Point import net.minestom.server.entity.EntityType import net.minestom.server.entity.PlayerSkin import net.minestom.server.event.Event @@ -22,13 +22,10 @@ import org.spongepowered.configurate.ConfigurationOptions import org.spongepowered.configurate.yaml.YamlConfigurationLoader import java.io.BufferedReader import java.nio.file.Paths -import kotlin.io.path.absolutePathString import kotlin.io.path.bufferedReader import kotlin.io.path.exists -import kotlin.io.path.reader -@SoftDependsOn(AnvilFileMapProviderModule::class) -class ConfigModule(private val configFileName: String? = null) : GameModule() { +class ConfigModule(private val configFileName: String? = null, private val mapSource: Maps.MapSource? = null) : GameModule() { private lateinit var root: ConfigurationNode private lateinit var mapRoot: ConfigurationNode @@ -39,20 +36,14 @@ class ConfigModule(private val configFileName: String? = null) : GameModule() { override fun initialize(parent: Game, eventNode: EventNode) { this.parent = parent + val mapSource = mapSource ?: parent.data.mapSource if (configFileName != null) { logger.info("Loading game configuration from $configFileName") root = loadFile(getReader(parent, configFileName)) } - if (parent.hasModule()) { - val worldFolder = parent.getModule().worldFolder - val file = worldFolder.resolve("config.yml") - if (file.exists()) { - logger.info("Loading map configuration from " + file.absolutePathString()) - mapRoot = loadFile(file.reader(Charsets.UTF_8).buffered()) - } else { - logger.info("No map configuration found at " + file.absolutePathString()) - } + runBlocking { + mapRoot = mapSource.config } logger.info("Configuration successfully loaded.") @@ -103,25 +94,25 @@ class ConfigModule(private val configFileName: String? = null) : GameModule() { } } - private fun loadFile(reader: BufferedReader): ConfigurationNode { + val SERIALIZATION_OPTIONS: ConfigurationOptions = ConfigurationOptions.defaults().serializers { builder -> + builder.register(Point::class.java, PointSerializer()) + builder.register(Color::class.java, ColorSerializer()) + builder.register(Component::class.java, ComponentSerializer()) + builder.register(EntityType::class.java, EntityTypeSerializer()) + builder.register(Material::class.java, MaterialSerializer()) + builder.register(EnchantmentList::class.java, EnchantmentListSerializer()) + builder.register(PlayerSkin::class.java, PlayerSkinSerializer()) + builder.register(KitsModule.Kit::class.java, KitSerializer()) + builder.register(ItemStack::class.java, ItemStackSerializer()) + builder.register(Block::class.java, BlockSerializer()) + } + fun loadFile(reader: BufferedReader): ConfigurationNode { val loader = YamlConfigurationLoader.builder() .source { reader } .build() - val config = ConfigurationOptions.defaults().serializers { builder -> - builder.register(Pos::class.java, PosSerializer()) - builder.register(Color::class.java, ColorSerializer()) - builder.register(Component::class.java, ComponentSerializer()) - builder.register(EntityType::class.java, EntityTypeSerializer()) - builder.register(Material::class.java, MaterialSerializer()) - builder.register(EnchantmentList::class.java, EnchantmentListSerializer()) - builder.register(PlayerSkin::class.java, PlayerSkinSerializer()) - builder.register(KitsModule.Kit::class.java, KitSerializer()) - builder.register(ItemStack::class.java, ItemStackSerializer()) - builder.register(Block::class.java, BlockSerializer()) - } - return loader.load(config) + return loader.load(SERIALIZATION_OPTIONS) } fun loadExtra(game: Game, fileName: String): ConfigurationNode? { diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/config/serializer/PointSerializer.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/config/serializer/PointSerializer.kt new file mode 100644 index 00000000..c25bd382 --- /dev/null +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/config/serializer/PointSerializer.kt @@ -0,0 +1,40 @@ +package com.bluedragonmc.server.module.config.serializer + +import net.minestom.server.coordinate.BlockVec +import net.minestom.server.coordinate.Point +import net.minestom.server.coordinate.Pos +import net.minestom.server.coordinate.Vec +import org.spongepowered.configurate.ConfigurationNode +import org.spongepowered.configurate.serialize.TypeSerializer +import java.lang.reflect.AnnotatedType + +class PointSerializer : TypeSerializer.Annotated { + override fun deserialize( + type: AnnotatedType?, + node: ConfigurationNode? + ): Point? { + if (node == null) return null + val string = node.raw().toString() + val split = string.split(",").map { it.trim().toDouble() } + return when (split.size) { + 3 -> Pos(split[0], split[1], split[2]) + 5 -> Pos(split[0], split[1], split[2], split[3].toFloat(), split[4].toFloat()) + else -> error("Invalid number of elements: ${split.size}: expected 3 or 5") + } + } + + override fun serialize( + type: AnnotatedType?, + item: Point?, + node: ConfigurationNode + ) { + val stringValue = when (item) { + is Pos -> item.run { "$x,$y,$z,$yaw,$pitch" } + is Vec -> item.run { "$x,$y,$z" } + is BlockVec -> item.run { "$blockX,$blockY,$blockZ" } + null -> error("Cannot serialize null Point") + } + + node.raw(stringValue) + } +} diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/config/serializer/PosSerializer.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/config/serializer/PosSerializer.kt deleted file mode 100644 index 063a4dca..00000000 --- a/common/src/main/kotlin/com/bluedragonmc/server/module/config/serializer/PosSerializer.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.bluedragonmc.server.module.config.serializer - -import net.minestom.server.coordinate.Pos -import org.spongepowered.configurate.serialize.ScalarSerializer -import java.lang.reflect.Type -import java.util.function.Predicate - -class PosSerializer : ScalarSerializer(Pos::class.java) { - override fun deserialize(type: Type?, obj: Any?): Pos { - val string = obj.toString() - val split = string.split(",").map { it.trim().toDouble() } - return when (split.size) { - 3 -> Pos(split[0], split[1], split[2]) - 5 -> Pos(split[0], split[1], split[2], split[3].toFloat(), split[4].toFloat()) - else -> error("Invalid number of elements: ${split.size}: expected 3 or 5") - } - } - - override fun serialize(item: Pos?, typeSupported: Predicate>?): Any { - return item?.run { "$x,$y,$z,$yaw,$pitch" } ?: error("Cannot serialize null Pos") - } -} \ No newline at end of file diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/database/AwardsModule.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/database/AwardsModule.kt index 1b6246ac..654e8311 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/module/database/AwardsModule.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/database/AwardsModule.kt @@ -2,6 +2,7 @@ package com.bluedragonmc.server.module.database import com.bluedragonmc.server.* import com.bluedragonmc.server.event.PlayerLeaveGameEvent +import com.bluedragonmc.server.Game import com.bluedragonmc.server.model.PlayerDocument import com.bluedragonmc.server.service.Database import com.bluedragonmc.server.module.GameModule diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/database/StatRecorders.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/database/StatRecorders.kt index 60ae58a4..82532cfb 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/module/database/StatRecorders.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/database/StatRecorders.kt @@ -59,7 +59,7 @@ object StatRecorders { ) private fun getStatPrefix(game: Game): String { - return if (game.mode.isNullOrBlank()) "game_${game.name.lowercase()}" - else "game_${game.name.lowercase()}_${game.mode.lowercase()}" + return if (game.data.mode.isNullOrBlank()) "game_${game.data.name.lowercase()}" + else "game_${game.data.name.lowercase()}_${game.data.mode.lowercase()}" } } \ 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 bc1d2c9e..21df0d9a 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 @@ -77,6 +77,21 @@ class StatisticsModule(private vararg val recorders: StatisticRecorder) : GameMo } } } + + suspend fun rankPlayersByStatistic( + key: String, + sortOrderBy: OrderBy = OrderBy.DESC, + limit: Int = 10, + ): Map { + + val cachedEntry = statisticsCache.getIfPresent(sortOrderBy.toString() + key) + if (cachedEntry != null) return cachedEntry.associateWith { it.statistics[key]!! } + + val documents = Database.connection.rankPlayersByStatistic(key, sortOrderBy.toString(), limit) + statisticsCache.put(sortOrderBy.toString() + key, documents) + + return documents.associateWith { it.statistics[key]!! } + } } override fun initialize(parent: Game, eventNode: EventNode) { @@ -224,21 +239,6 @@ class StatisticsModule(private vararg val recorders: StatisticRecorder) : GameMo ASC, DESC } - suspend fun rankPlayersByStatistic( - key: String, - sortOrderBy: OrderBy = OrderBy.DESC, - limit: Int = 10, - ): Map { - - val cachedEntry = statisticsCache.getIfPresent(sortOrderBy.toString() + key) - if (cachedEntry != null) return cachedEntry.associateWith { it.statistics[key]!! } - - val documents = Database.connection.rankPlayersByStatistic(key, sortOrderBy.toString(), limit) - statisticsCache.put(sortOrderBy.toString() + key, documents) - - return documents.associateWith { it.statistics[key]!! } - } - class EventStatisticRecorder( private val eventType: Class, val handler: suspend StatisticsModule.(Game, T) -> Unit, diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/gameplay/SidebarModule.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/gameplay/SidebarModule.kt index 7f8e76d2..71d2ab69 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/module/gameplay/SidebarModule.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/gameplay/SidebarModule.kt @@ -40,10 +40,12 @@ class SidebarModule(private val title: String) : GameModule() { binding.updateFor(player) } eventNode.addListener(PlayerJoinGameEvent::class.java) { event -> - val sidebar = sidebars.getOrPut(event.player) { createSidebar() } - sidebar.addViewer(event.player) - if (::binding.isInitialized) - binding.updateFor(event.player) + MinecraftServer.getSchedulerManager().scheduleNextTick { + val sidebar = sidebars.getOrPut(event.player) { createSidebar() } + sidebar.addViewer(event.player) + if (::binding.isInitialized) + binding.updateFor(event.player) + } } eventNode.addListener(PlayerLeaveGameEvent::class.java) { event -> sidebars[event.player]?.removeViewer(event.player) diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/instance/InstanceContainerModule.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/instance/InstanceContainerModule.kt index 36b6881f..5ee5d260 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/module/instance/InstanceContainerModule.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/instance/InstanceContainerModule.kt @@ -2,16 +2,15 @@ package com.bluedragonmc.server.module.instance import com.bluedragonmc.server.Game import com.bluedragonmc.server.module.DependsOn -import com.bluedragonmc.server.module.map.AnvilFileMapProviderModule +import com.bluedragonmc.server.module.map.MapProviderModule import net.minestom.server.MinecraftServer import net.minestom.server.entity.Player import net.minestom.server.event.Event import net.minestom.server.event.EventNode import net.minestom.server.instance.Instance import net.minestom.server.instance.InstanceContainer -import net.minestom.server.instance.anvil.AnvilLoader -@DependsOn(AnvilFileMapProviderModule::class) +@DependsOn(MapProviderModule::class) class InstanceContainerModule : InstanceModule() { private lateinit var instance: InstanceContainer @@ -21,8 +20,9 @@ class InstanceContainerModule : InstanceModule() { override fun initialize(parent: Game, eventNode: EventNode) { // Create a copy of the loaded InstanceContainer to prevent modifying the state of the original - this.instance = parent.getModule().instanceContainer.copy().apply { - chunkLoader = AnvilLoader(parent.getModule().worldFolder) + val mapProviderModule = parent.getModule() + this.instance = mapProviderModule.instanceContainer.copy().apply { + chunkLoader = mapProviderModule.instanceContainer.chunkLoader } MinecraftServer.getInstanceManager().registerInstance(instance) } diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/instance/SharedInstanceModule.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/instance/SharedInstanceModule.kt index c1f2fd5b..6f2910e2 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/module/instance/SharedInstanceModule.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/instance/SharedInstanceModule.kt @@ -2,7 +2,7 @@ package com.bluedragonmc.server.module.instance import com.bluedragonmc.server.Game import com.bluedragonmc.server.module.DependsOn -import com.bluedragonmc.server.module.map.AnvilFileMapProviderModule +import com.bluedragonmc.server.module.map.MapProviderModule import net.minestom.server.MinecraftServer import net.minestom.server.entity.Player import net.minestom.server.event.Event @@ -11,7 +11,7 @@ import net.minestom.server.instance.Instance import net.minestom.server.instance.InstanceContainer import net.minestom.server.instance.SharedInstance -@DependsOn(AnvilFileMapProviderModule::class) +@DependsOn(MapProviderModule::class) class SharedInstanceModule : InstanceModule() { private lateinit var instanceContainer: InstanceContainer @@ -27,7 +27,7 @@ class SharedInstanceModule : InstanceModule() { } override fun initialize(parent: Game, eventNode: EventNode) { - instanceContainer = parent.getModule().instanceContainer + instanceContainer = parent.getModule().instanceContainer if (!instanceContainer.isRegistered) { MinecraftServer.getInstanceManager().registerInstance(instanceContainer) } diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/map/AnvilFileMapProviderModule.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/map/MapProviderModule.kt similarity index 63% rename from common/src/main/kotlin/com/bluedragonmc/server/module/map/AnvilFileMapProviderModule.kt rename to common/src/main/kotlin/com/bluedragonmc/server/module/map/MapProviderModule.kt index 54c63c01..0e19a91c 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/module/map/AnvilFileMapProviderModule.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/map/MapProviderModule.kt @@ -2,19 +2,19 @@ package com.bluedragonmc.server.module.map import com.bluedragonmc.server.Game import com.bluedragonmc.server.module.GameModule +import com.bluedragonmc.server.service.Database +import com.bluedragonmc.server.service.Maps +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking import net.minestom.server.MinecraftServer import net.minestom.server.event.Event import net.minestom.server.event.EventNode 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.instance.* import net.minestom.server.registry.RegistryKey import net.minestom.server.tag.Tag import net.minestom.server.world.DimensionType -import java.nio.file.Path -import kotlin.io.path.absolutePathString /** * Supplies an [InstanceContainer] to the [com.bluedragonmc.server.module.instance.InstanceContainerModule]. @@ -29,29 +29,33 @@ import kotlin.io.path.absolutePathString * * [See Documentation](https://developer.bluedragonmc.com/modules/anvilfilemapprovidermodule/) */ -class AnvilFileMapProviderModule(val worldFolder: Path, private val dimensionType: RegistryKey = DimensionType.OVERWORLD) : GameModule() { - +class MapProviderModule( + val mapSource: Maps.MapSource, + private val dimensionType: RegistryKey = DimensionType.OVERWORLD +) : GameModule() { lateinit var instanceContainer: InstanceContainer private set override fun initialize(parent: Game, eventNode: EventNode) { // If this world has already been loaded, use its existing InstanceContainer - if (loadedMaps.containsKey(worldFolder)) { - instanceContainer = loadedMaps[worldFolder]!! + if (loadedMaps.containsKey(mapSource.id)) { + instanceContainer = loadedMaps[mapSource.id]!! return } // If not, create a new InstanceContainer instanceContainer = MinecraftServer.getInstanceManager().createInstanceContainer(dimensionType) - instanceContainer.chunkLoader = AnvilLoader(worldFolder) + instanceContainer.chunkLoader = DeferredChunkLoader(Database.IO.async { + Maps.provideMap(mapSource) + }) instanceContainer.setChunkSupplier(::LightingChunk) - instanceContainer.setTag(MAP_NAME_TAG, worldFolder.absolutePathString()) + instanceContainer.setTag(MAP_NAME_TAG, mapSource.id) - loadedMaps[worldFolder] = instanceContainer + loadedMaps[mapSource.id] = instanceContainer } companion object { - val loadedMaps = mutableMapOf() + val loadedMaps = mutableMapOf() val MAP_NAME_TAG = Tag.String("anvil_file_map_name") init { @@ -61,4 +65,12 @@ class AnvilFileMapProviderModule(val worldFolder: Path, private val dimensionTyp } } } + + private class DeferredChunkLoader(val delegate: Deferred) : ChunkLoader { + override fun loadChunk( + p0: Instance?, p1: Int, p2: Int + ): Chunk? = runBlocking { delegate.await().loadChunk(p0, p1, p2) } + + override fun saveChunk(p0: Chunk?) = runBlocking { delegate.await().saveChunk(p0) } + } } diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/minigame/MOTDModule.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/minigame/MOTDModule.kt index 7f169ce7..8aaf0cb1 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/module/minigame/MOTDModule.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/minigame/MOTDModule.kt @@ -39,7 +39,7 @@ class MOTDModule(private val motd: Component, private var showMapName: Boolean = event.player.sendMessage( buildComponent { // Game name - +Component.text(parent.name, BRAND_COLOR_PRIMARY_1, TextDecoration.BOLD) + +Component.text(parent.data.name, BRAND_COLOR_PRIMARY_1, TextDecoration.BOLD) +Component.newline() +buildComponent { // MOTD diff --git a/common/src/main/kotlin/com/bluedragonmc/server/module/vanilla/PickItemModule.kt b/common/src/main/kotlin/com/bluedragonmc/server/module/vanilla/PickItemModule.kt index 50dbc16b..6a55b53b 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/module/vanilla/PickItemModule.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/module/vanilla/PickItemModule.kt @@ -2,31 +2,112 @@ package com.bluedragonmc.server.module.vanilla import com.bluedragonmc.server.Game import com.bluedragonmc.server.module.GameModule +import net.minestom.server.MinecraftServer +import net.minestom.server.codec.Transcoder +import net.minestom.server.component.DataComponent +import net.minestom.server.component.DataComponents +import net.minestom.server.entity.GameMode import net.minestom.server.event.Event import net.minestom.server.event.EventNode +import net.minestom.server.event.player.PlayerBlockPlaceEvent import net.minestom.server.event.player.PlayerPickBlockEvent import net.minestom.server.instance.block.Block +import net.minestom.server.inventory.PlayerInventory +import net.minestom.server.item.ItemStack +import net.minestom.server.item.component.TypedCustomData +import net.minestom.server.registry.RegistryTranscoder /** - * Enables the vanilla "pick block" functionality (middle click) for survival mode + * Enables the vanilla "pick block" functionality (middle click) */ class PickItemModule : GameModule() { override fun initialize( parent: Game, eventNode: EventNode ) { + // If a player places a block with block entity data, add the data to the new block + // (this should probably be part of minestom or the block handlers) + eventNode.addListener(PlayerBlockPlaceEvent::class.java) { event -> + val blockItem = event.player.getItemInHand(event.hand) + val blockEntityData = blockItem.get(DataComponents.BLOCK_ENTITY_DATA) + if (blockEntityData != null) { + event.block = event.block.withNbt(blockEntityData.nbt) + } + } + eventNode.addListener(PlayerPickBlockEvent::class.java) { event -> val block = event.instance.getBlock(event.blockPosition) - if (event.player.inventory.getItemStack(event.player.heldSlot.toInt()).material().block()?.compare(block, Block.Comparator.ID) == true) { + if (block.isAir) return@addListener + val inventory = event.player.inventory + val includeData = event.isIncludeData && event.player.gameMode == GameMode.CREATIVE + if (inventory.getItemStack(event.player.heldSlot.toInt()).compareBlock(block, includeData)) { // If the player is already holding a matching block, do nothing return@addListener } + + // If the item is already in the hotbar, swap to it for (slot in 0..8) { - if (event.player.inventory.getItemStack(slot).material().block()?.compare(block, Block.Comparator.ID) == true) { + if (inventory.getItemStack(slot).compareBlock(block, includeData)) { + event.player.setHeldItemSlot(slot.toByte()) + return@addListener + } + } + + // If the item is elsewhere in the inventory, move it to the player's hand + for (slot in 9 until inventory.size) { + if (inventory.getItemStack(slot).compareBlock(block, includeData)) { + val newSlot = inventory.getEmptyHotbarSlot(default = event.player.heldSlot.toInt()) + inventory.swap(slot, newSlot) event.player.setHeldItemSlot(slot.toByte()) return@addListener } } + + // If the player is in creative, give them a new item stack + if (event.player.gameMode == GameMode.CREATIVE) { + val material = block.registry()!!.material() ?: return@addListener + val itemStack = ItemStack.builder(material).apply { + val blockEntityType = block.registry()!!.blockEntityType() + if (includeData && blockEntityType != null) { + set(DataComponents.BLOCK_ENTITY_DATA, TypedCustomData(blockEntityType, block.nbtOrEmpty())) + block.nbtOrEmpty().forEach { nbt -> + val coder = RegistryTranscoder(Transcoder.NBT, MinecraftServer.process()) + val component = DataComponent.fromKey("minecraft:${nbt.key.lowercase()}") as DataComponent? + if (component == null) { + logger.warn("Invalid block data component: ${nbt.key}") + return@forEach + } + component.decode(coder, nbt.value).mapResult { result: Any -> + set(component, result) + } + } + } + }.build() + val slot = inventory.getEmptyHotbarSlot(default = event.player.heldSlot.toInt()) + inventory.setItemStack(slot, itemStack) + event.player.setHeldItemSlot(slot.toByte()) + } } } +} + +private fun ItemStack.compareBlock(block: Block, includeData: Boolean): Boolean { + return if (includeData) { + compareBlock(block, false) && + get(DataComponents.BLOCK_ENTITY_DATA)?.nbt == block.nbt() + } else { + material().block()?.compare(block, Block.Comparator.ID) == true + } +} +private fun PlayerInventory.getEmptyHotbarSlot(default: Int): Int { + for (slot in 0 .. 8) { + if (getItemStack(slot).isAir) return slot + } + return default +} +private fun PlayerInventory.swap(slot1: Int, slot2: Int) { + val item1 = getItemStack(slot1) + val item2 = getItemStack(slot2) + setItemStack(slot1, item2) + setItemStack(slot2, item1) } \ No newline at end of file diff --git a/common/src/main/kotlin/com/bluedragonmc/server/service/Maps.kt b/common/src/main/kotlin/com/bluedragonmc/server/service/Maps.kt new file mode 100644 index 00000000..8a4b6705 --- /dev/null +++ b/common/src/main/kotlin/com/bluedragonmc/server/service/Maps.kt @@ -0,0 +1,96 @@ +package com.bluedragonmc.server.service + +import com.bluedragonmc.api.grpc.CommonTypes +import com.bluedragonmc.server.module.config.ConfigModule +import net.minestom.server.instance.ChunkLoader +import net.minestom.server.instance.InstanceContainer +import org.spongepowered.configurate.ConfigurationNode +import org.spongepowered.configurate.gson.GsonConfigurationLoader +import org.spongepowered.configurate.objectmapping.ConfigSerializable +import java.io.BufferedReader +import java.io.StringReader +import java.util.* + +object Maps { + data class MapSource( + /** + * The unique identifier for this map. + */ + val id: String, + /** + * URL for the binary data representing the map. + */ + val url: String, + /** + * The format used to encode this map's data. + */ + val format: CommonTypes.MapFormat, + /** + * The map's root configuration node. By convention, map-specific entries are under the "world" node. + */ + val config: ConfigurationNode, + ) { + constructor(id: String, url: String, format: CommonTypes.MapFormat, config: String) : this( + id, url, format, + ConfigModule.loadFile(BufferedReader(StringReader(config))) + ) + + val games: List by lazy { config.node("world", "games").getList(GameEntry::class.java)!! } + val whitelist: List? by lazy { + if (!config.node("world").hasChild("whitelist")) return@lazy null + config.node("world", "whitelist").getList(UUID::class.java) + } + + /** + * Returns true if this map is playable on the specified game, false otherwise. + */ + infix fun matches(gameType: CommonTypes.GameType): Boolean = + (!gameType.hasMapId() || gameType.mapId == id) + && games.any { game -> + game.name == gameType.name + && (game.mode == null || game.mode == gameType.mode) + } + + /** + * Returns true if the player is not blocked from joining this map by the whitelist. + */ + fun isPlayerAllowed(playerUuid: UUID) = whitelist?.contains(playerUuid) != false + } + + @ConfigSerializable + data class GameEntry( + val name: String, + val mode: String?, + ) { + // for configurate + constructor() : this("", "") + } + + abstract class MapProvider { + abstract suspend fun provideMap(source: MapSource): L + + /** + * Posts the binary contents of the map to the map's URL. + */ + abstract suspend fun saveMap(source: MapSource, instance: InstanceContainer) + } + + private val mapProviders = mutableMapOf>() + + suspend fun provideMap(source: MapSource): ChunkLoader = + mapProviders[source.format]?.provideMap(source) + ?: error("No valid map provider found to fulfill request: $source") + + suspend fun saveMap(source: MapSource, instance: InstanceContainer) = + (mapProviders[source.format] as? MapProvider)?.saveMap(source, instance) + ?: error("No valid map provider found to fulfill save request: $source") + + suspend fun saveMapConfig(source: MapSource, config: ConfigurationNode) { + val json = GsonConfigurationLoader.builder().buildAndSaveString(config) + Messaging.outgoing.updateMapConfig(source.id, json) + } + + fun registerMapProvider(format: CommonTypes.MapFormat, mapProvider: MapProvider<*>) { + mapProviders[format] = mapProvider + } +} diff --git a/common/src/main/kotlin/com/bluedragonmc/server/utils/InstanceUtils.kt b/common/src/main/kotlin/com/bluedragonmc/server/utils/InstanceUtils.kt index 5350da77..e3e12584 100644 --- a/common/src/main/kotlin/com/bluedragonmc/server/utils/InstanceUtils.kt +++ b/common/src/main/kotlin/com/bluedragonmc/server/utils/InstanceUtils.kt @@ -1,6 +1,5 @@ package com.bluedragonmc.server.utils -import com.bluedragonmc.api.grpc.CommonTypes.GameType.GameTypeFieldSelector import com.bluedragonmc.api.grpc.gameType import com.bluedragonmc.server.Game import com.bluedragonmc.server.api.Environment @@ -48,7 +47,7 @@ object InstanceUtils { return CompletableFuture.completedFuture(null) } else { // If the instance is not empty, attempt to send all players to a lobby - val lobby = Game.games.find { it.name == Environment.defaultGameName } + val lobby = Game.games.find { it.data.name == Environment.defaultGameName } if (lobby != null) { return CompletableFuture.allOf( *instance.players.map { @@ -64,7 +63,6 @@ object InstanceUtils { instance.players.forEach { Environment.queue.queue(it, gameType { name = Environment.defaultGameName - selectors += GameTypeFieldSelector.GAME_NAME }) } }.repeat(Duration.ofSeconds(10)).schedule() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d5cc46fa..0618d673 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,8 +13,9 @@ serialization = "1.8.0" tinylog = "2.7.0" atlas-projectiles = "2.1.2" fastutil = "8.5.18" +polar = "1.15.1" # Auto-generated GRPC/Protobuf messaging code -rpc = "d40ac743b5" +rpc = "aee4852ca5" # Messaging dependencies grpc = "1.71.0" grpc-kotlin-stub = "1.4.1" @@ -28,7 +29,8 @@ kotlin-jvm = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", ve minestom = { group = "net.minestom", name = "minestom", version.ref = "minestom" } jukebox = { group = "com.github.BlueDragonMC", name = "Jukebox", version.ref = "jukebox" } #jukebox = { group = "com.bluedragonmc", name = "Jukebox", version = "1.0-SNAPSHOT" } -configurate = { group = "org.spongepowered", name = "configurate-yaml", version.ref = "configurate" } +configurate-yaml = { group = "org.spongepowered", name = "configurate-yaml", version.ref = "configurate" } +configurate-gson = { group = "org.spongepowered", name = "configurate-gson", version.ref = "configurate" } configurate-extra-kotlin = { group = "org.spongepowered", name = "configurate-extra-kotlin", version.ref = "configurate" } minimessage = { group = "net.kyori", name = "adventure-text-minimessage", version.ref = "minimessage" } atlas-projectiles = { group = "ca.atlasengine", name = "atlas-projectiles", version.ref = "atlas-projectiles" } @@ -50,8 +52,9 @@ junit-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", ver tinylog-api = { group = "org.tinylog", name = "tinylog-api", version.ref = "tinylog"} tinylog-impl = { group = "org.tinylog", name = "tinylog-impl", version.ref = "tinylog"} tinylog-slf4j = { group = "org.tinylog", name = "slf4j-tinylog", version.ref = "tinylog"} +polar = { group = "dev.hollowcube", name = "polar", version.ref = "polar"} [bundles] -configurate = ["configurate", "configurate-extra-kotlin"] +configurate = ["configurate-yaml", "configurate-gson", "configurate-extra-kotlin"] messaging = ["rpc", "grpc-protobuf", "grpc-netty", "grpc-kotlin-stub", "protobuf-kotlin"] tinylog = ["tinylog-api", "tinylog-impl", "tinylog-slf4j"] \ No newline at end of file diff --git a/src/main/kotlin/com/bluedragonmc/server/Server.kt b/src/main/kotlin/com/bluedragonmc/server/Server.kt index 8e64bdb6..fb68343f 100644 --- a/src/main/kotlin/com/bluedragonmc/server/Server.kt +++ b/src/main/kotlin/com/bluedragonmc/server/Server.kt @@ -15,8 +15,6 @@ import java.text.DateFormat import kotlin.system.exitProcess import kotlin.system.measureTimeMillis -lateinit var lobby: Game -fun isLobbyInitialized() = ::lobby.isInitialized private val logger = LoggerFactory.getLogger("ServerKt") fun main() { @@ -58,7 +56,6 @@ fun start() { Commands, CustomPlayerProvider, DefaultDimensionTypes, - DevInstanceRouter, GlobalBlockHandlers, GlobalChatFormat, GlobalPlayerNameFormat, @@ -72,6 +69,7 @@ fun start() { PerInstanceTabList, ServerListPingHandler, TabListFormat, + DevInstanceRouter, ).filter { it.canHook() } // Load game plugins and preinitialize their main classes @@ -94,14 +92,4 @@ fun start() { // Start the server & bind to port 25565 minecraftServer.start("0.0.0.0", 25565) - - // Create a Lobby instance - lobby = try { - GameLoader.createNewGame(Environment.defaultGameName, null, null) - } catch (e: Throwable) { - logger.error("There was an error initializing the Lobby. Shutting down...") - e.printStackTrace() - MinecraftServer.stopCleanly() - exitProcess(1) - } } diff --git a/src/main/kotlin/com/bluedragonmc/server/bootstrap/GlobalChatFormat.kt b/src/main/kotlin/com/bluedragonmc/server/bootstrap/GlobalChatFormat.kt index 03f2af15..dc7a8c04 100644 --- a/src/main/kotlin/com/bluedragonmc/server/bootstrap/GlobalChatFormat.kt +++ b/src/main/kotlin/com/bluedragonmc/server/bootstrap/GlobalChatFormat.kt @@ -11,6 +11,7 @@ import com.bluedragonmc.server.utils.surroundWithSeparators import net.kyori.adventure.text.Component import net.kyori.adventure.text.event.HoverEvent import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.`object`.ObjectContents import net.minestom.server.event.Event import net.minestom.server.event.EventFilter import net.minestom.server.event.EventNode @@ -45,6 +46,9 @@ object GlobalChatFormat : Bootstrap() { Component.text(xpToNextLevel, ALT_COLOR_1), Component.text(level.toInt() + 1, ALT_COLOR_2)))) + +Component.`object`(ObjectContents.playerHead(player.uuid)) + +Component.space() + +player.name +Component.text(": ", NamedTextColor.DARK_GRAY) if (Permissions.hasPermission(player.uuid, "chat.minimessage") == true) diff --git a/src/main/kotlin/com/bluedragonmc/server/bootstrap/IntegrationsInit.kt b/src/main/kotlin/com/bluedragonmc/server/bootstrap/IntegrationsInit.kt index 907e9700..25bb795c 100644 --- a/src/main/kotlin/com/bluedragonmc/server/bootstrap/IntegrationsInit.kt +++ b/src/main/kotlin/com/bluedragonmc/server/bootstrap/IntegrationsInit.kt @@ -1,13 +1,12 @@ package com.bluedragonmc.server.bootstrap +import com.bluedragonmc.api.grpc.CommonTypes import com.bluedragonmc.server.api.Environment import com.bluedragonmc.server.api.IncomingRPCHandlerStub import com.bluedragonmc.server.api.OutgoingRPCHandlerStub -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.impl.* import com.bluedragonmc.server.service.Database +import com.bluedragonmc.server.service.Maps import com.bluedragonmc.server.service.Messaging import com.bluedragonmc.server.service.Permissions import kotlinx.coroutines.runBlocking @@ -19,6 +18,8 @@ object IntegrationsInit : Bootstrap() { override fun hook(eventNode: EventNode) { Database.initialize(DatabaseConnectionImpl(Environment.mongoConnectionString)) Permissions.initialize(PermissionManagerImpl()) + Maps.registerMapProvider(CommonTypes.MapFormat.POLAR, PolarMapProvider()) + Maps.registerMapProvider(CommonTypes.MapFormat.ANVIL, AnvilMapProvider()) if (Environment.current.isDev) { logger.info("Using no-op stubs for messaging as this server is in a development environment.") diff --git a/src/main/kotlin/com/bluedragonmc/server/bootstrap/Jukebox.kt b/src/main/kotlin/com/bluedragonmc/server/bootstrap/Jukebox.kt index 7a2bd8fa..a9d33d37 100644 --- a/src/main/kotlin/com/bluedragonmc/server/bootstrap/Jukebox.kt +++ b/src/main/kotlin/com/bluedragonmc/server/bootstrap/Jukebox.kt @@ -1,19 +1,16 @@ package com.bluedragonmc.server.bootstrap -import com.bluedragonmc.api.grpc.JukeboxOuterClass -import com.bluedragonmc.api.grpc.copy -import com.bluedragonmc.api.grpc.playerSongInfo -import com.bluedragonmc.api.grpc.playerSongQueue +import com.bluedragonmc.api.grpc.* import com.bluedragonmc.jukebox.api.Song import com.bluedragonmc.jukebox.impl.NBSSongLoader import com.bluedragonmc.server.Game +import com.bluedragonmc.server.game.GameData import com.bluedragonmc.server.module.GuiModule +import com.bluedragonmc.server.service.Maps import com.bluedragonmc.server.service.Messaging import com.google.protobuf.Timestamp -import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import net.kyori.adventure.sound.Sound import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor @@ -25,7 +22,6 @@ import net.minestom.server.entity.Player import net.minestom.server.event.Event import net.minestom.server.event.EventListener import net.minestom.server.event.EventNode -import net.minestom.server.event.player.PlayerDisconnectEvent import net.minestom.server.event.player.PlayerLoadedEvent import net.minestom.server.event.player.PlayerSpawnEvent import net.minestom.server.instance.block.jukebox.JukeboxSong @@ -38,9 +34,7 @@ import net.minestom.server.timer.Task import java.io.File import java.nio.file.Paths import java.time.Duration -import java.util.WeakHashMap -import java.util.concurrent.CompletableFuture -import kotlin.coroutines.suspendCoroutine +import java.util.* import kotlin.io.path.exists import kotlin.math.PI import kotlin.math.pow @@ -177,7 +171,7 @@ object Jukebox : Bootstrap() { } private val loader = NBSSongLoader() - private val emptyGame = object : Game("", "") { + private val emptyGame = object : Game(GameData("", Maps.MapSource("", "", CommonTypes.MapFormat.UNRECOGNIZED, ""))) { // Used as a placeholder when registering the GuiModule under this Bootstrap's event node override fun initialize() {} } diff --git a/src/main/kotlin/com/bluedragonmc/server/bootstrap/dev/DevInstanceRouter.kt b/src/main/kotlin/com/bluedragonmc/server/bootstrap/dev/DevInstanceRouter.kt index 63ed883a..3877913c 100644 --- a/src/main/kotlin/com/bluedragonmc/server/bootstrap/dev/DevInstanceRouter.kt +++ b/src/main/kotlin/com/bluedragonmc/server/bootstrap/dev/DevInstanceRouter.kt @@ -1,24 +1,38 @@ package com.bluedragonmc.server.bootstrap.dev +import com.bluedragonmc.api.grpc.CommonTypes import com.bluedragonmc.server.CustomPlayer +import com.bluedragonmc.server.api.Environment import com.bluedragonmc.server.bootstrap.Bootstrap import com.bluedragonmc.server.bootstrap.prod.InitialInstanceRouter.DATA_LOAD_FAILED -import com.bluedragonmc.server.isLobbyInitialized -import com.bluedragonmc.server.lobby +import com.bluedragonmc.server.Game +import com.bluedragonmc.server.game.GameData import com.bluedragonmc.server.module.minigame.SpawnpointModule +import com.bluedragonmc.server.queue.GameLoader import com.bluedragonmc.server.service.Database -import com.bluedragonmc.server.utils.listen +import com.bluedragonmc.server.service.Maps import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout -import net.minestom.server.MinecraftServer import net.minestom.server.event.Event import net.minestom.server.event.EventNode -import net.minestom.server.event.instance.InstanceTickEvent import net.minestom.server.event.player.AsyncPlayerConfigurationEvent import net.minestom.server.event.player.PlayerSpawnEvent +import java.io.File object DevInstanceRouter : Bootstrap(EnvType.DEVELOPMENT) { + + lateinit var lobby: Game + override fun hook(eventNode: EventNode) { + + val worldsFolder = File("worlds/${Environment.defaultGameName}").listFiles().first() + lobby = GameLoader.createNewGame( + GameData( + Environment.defaultGameName, + Maps.MapSource(worldsFolder.name, "file://${worldsFolder.absolutePath}", CommonTypes.MapFormat.ANVIL, File(worldsFolder, "config.yml").readText()) + ) + ) + eventNode.addListener(PlayerSpawnEvent::class.java) { event -> // When a player logs in and spawns in the lobby, add them to the lobby's player list if (event.isFirstSpawn && event.instance == lobby.getInstance()) { @@ -37,27 +51,12 @@ object DevInstanceRouter : Bootstrap(EnvType.DEVELOPMENT) { event.player.kick(DATA_LOAD_FAILED) } - if (isLobbyInitialized()) { - // Send the player to the lobby - event.spawningInstance = lobby.getInstance() - val spawnpoint = - lobby.getModuleOrNull()?.spawnpointProvider?.getSpawnpoint(event.player) - if (spawnpoint != null) { - event.player.respawnPoint = spawnpoint - } - } else { - // Send the player to a temporary "limbo" instance while the lobby is being loaded - val instance = MinecraftServer.getInstanceManager().createInstanceContainer() - instance.enableAutoChunkLoad(false) - instance.eventNode().listen { - if (instance.players.isEmpty()) { - MinecraftServer.getInstanceManager().unregisterInstance(instance) - } - if (isLobbyInitialized()) { - lobby.addPlayer(event.player) - } - } - event.spawningInstance = instance + // Send the player to the lobby + event.spawningInstance = lobby.getInstance() + val spawnpoint = + lobby.getModuleOrNull()?.spawnpointProvider?.getSpawnpoint(event.player) + if (spawnpoint != null) { + event.player.respawnPoint = spawnpoint } } } diff --git a/src/main/kotlin/com/bluedragonmc/server/bootstrap/prod/AgonesIntegration.kt b/src/main/kotlin/com/bluedragonmc/server/bootstrap/prod/AgonesIntegration.kt index 29c58a5c..6eaa75c1 100644 --- a/src/main/kotlin/com/bluedragonmc/server/bootstrap/prod/AgonesIntegration.kt +++ b/src/main/kotlin/com/bluedragonmc/server/bootstrap/prod/AgonesIntegration.kt @@ -3,7 +3,6 @@ package com.bluedragonmc.server.bootstrap.prod import agones.dev.sdk.Agones import agones.dev.sdk.SDKGrpcKt import agones.dev.sdk.duration -import com.bluedragonmc.server.Game import com.bluedragonmc.server.bootstrap.Bootstrap import com.bluedragonmc.server.service.Database import com.bluedragonmc.server.service.Messaging @@ -86,8 +85,6 @@ object AgonesIntegration : Bootstrap(EnvType.PRODUCTION) { } private fun isHealthy(): Boolean { - // Verify that at least one game is running (Lobby) - if (Game.games.isEmpty()) return false // Verify that the local gRPC server is running if (!Messaging.isConnected()) return false // Verify that the server is ticking (the tick thread isn't blocked) 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 f4586216..7d07286a 100644 --- a/src/main/kotlin/com/bluedragonmc/server/bootstrap/prod/InitialInstanceRouter.kt +++ b/src/main/kotlin/com/bluedragonmc/server/bootstrap/prod/InitialInstanceRouter.kt @@ -81,7 +81,7 @@ object InitialInstanceRouter : Bootstrap(EnvType.PRODUCTION) { } else { logger.warn("Invalid destination ('$destination') supplied for player ${event.player.username}, sending to Lobby.") // If no destination was found, send the player to a lobby. - Game.games.find { it.name.equals(Environment.defaultGameName, ignoreCase = true) } + Game.games.find { it.data.name.equals(Environment.defaultGameName, ignoreCase = true) } } // Spawn the player in the game's spawning instance diff --git a/src/main/kotlin/com/bluedragonmc/server/command/GameCommand.kt b/src/main/kotlin/com/bluedragonmc/server/command/GameCommand.kt index 35d676cd..8dbe344d 100644 --- a/src/main/kotlin/com/bluedragonmc/server/command/GameCommand.kt +++ b/src/main/kotlin/com/bluedragonmc/server/command/GameCommand.kt @@ -83,9 +83,9 @@ class GameCommand(name: String, usageString: String, vararg aliases: String?) : buildComponent { +text(it.id, BRAND_COLOR_PRIMARY_1) +text(" · ", NamedTextColor.GRAY) - +text(it.name, BRAND_COLOR_PRIMARY_1) + +text(it.data.name, BRAND_COLOR_PRIMARY_1) +text(" · ", NamedTextColor.GRAY) - +text(it.mapName, BRAND_COLOR_PRIMARY_1) + +text(it.data.mapSource.id, BRAND_COLOR_PRIMARY_1) +text(" · ", NamedTextColor.GRAY) +text(it.players.size, BRAND_COLOR_PRIMARY_1) .hoverEvent(text(it.players.joinToString { it.username })) @@ -134,34 +134,4 @@ class GameCommand(name: String, usageString: String, vararg aliases: String?) : } } } - - subcommand("create") { - val gameType by WordArgument - val mapName by StringArgument - val modeArgument by StringArgument - - fun create(sender: CommandSender, type: String, map: String, mode: String) { - val newGame = Environment.queue.createInstance( - GsClient.CreateInstanceRequest.newBuilder() - .setGameType(GameType.newBuilder() - .setName(type) - .setMapName(map) - .setMode(mode)).setCorrelationId(UUID.randomUUID().toString()).build() - ) - - if (newGame != null) { - sender.sendMessage(formatMessageTranslated("command.game.create.success", newGame.id)) - } else { - sender.sendMessage(formatErrorTranslated("command.game.create.failed")) - } - } - - syntax(gameType, mapName) { - create(sender, get(gameType), get(mapName), "") - } - - syntax(gameType, mapName, modeArgument) { - create(sender, get(gameType), get(mapName), get(modeArgument)) - } - } }) \ No newline at end of file diff --git a/src/main/kotlin/com/bluedragonmc/server/command/InstanceCommand.kt b/src/main/kotlin/com/bluedragonmc/server/command/InstanceCommand.kt index 5b3d70f7..74c0d320 100644 --- a/src/main/kotlin/com/bluedragonmc/server/command/InstanceCommand.kt +++ b/src/main/kotlin/com/bluedragonmc/server/command/InstanceCommand.kt @@ -3,7 +3,7 @@ package com.bluedragonmc.server.command import com.bluedragonmc.server.BRAND_COLOR_PRIMARY_1 import com.bluedragonmc.server.BRAND_COLOR_PRIMARY_2 import com.bluedragonmc.server.Game -import com.bluedragonmc.server.module.map.AnvilFileMapProviderModule.Companion.MAP_NAME_TAG +import com.bluedragonmc.server.module.map.MapProviderModule.Companion.MAP_NAME_TAG import com.bluedragonmc.server.module.minigame.SpawnpointModule import com.bluedragonmc.server.utils.* import net.kyori.adventure.text.Component.* @@ -48,11 +48,11 @@ class InstanceCommand(name: String, usageString: String, vararg aliases: String? ) } else { // Game name - if (game?.name != null) { - +text(game.name, NamedTextColor.YELLOW) - if (game.mode != null) { + if (game?.data?.name != null) { + +text(game.data.name, NamedTextColor.YELLOW) + if (game.data.mode != null) { +text(" (", NamedTextColor.GRAY) - +text(game.mode!!, NamedTextColor.DARK_GREEN) + +text(game.data.mode!!, NamedTextColor.DARK_GREEN) +text(")", NamedTextColor.GRAY) } } else { @@ -60,8 +60,8 @@ class InstanceCommand(name: String, usageString: String, vararg aliases: String? } +text(" · ", NamedTextColor.GRAY) // Map name - if (game?.mapName != null) { - +text(game.mapName, NamedTextColor.GOLD) + if (game?.data?.mapSource?.id != null) { + +text(game.data.mapSource.id, NamedTextColor.GOLD) } else { +translatable("command.instance.no_map", NamedTextColor.RED) } @@ -83,11 +83,11 @@ class InstanceCommand(name: String, usageString: String, vararg aliases: String? "command.instance.required_by", NamedTextColor.GRAY, text(game.id, BRAND_COLOR_PRIMARY_1).hoverEvent( - text(game.name, NamedTextColor.YELLOW) + + text(game.data.name, NamedTextColor.YELLOW) + text(" · ", NamedTextColor.GRAY) + - text(game.mapName, NamedTextColor.GOLD) + + text(game.data.mapSource.id, NamedTextColor.GOLD) + text(" · ", NamedTextColor.GRAY) + - text(game.mode ?: "--", NamedTextColor.DARK_GREEN) + text(game.data.mode ?: "--", NamedTextColor.DARK_GREEN) ) ) } diff --git a/src/main/kotlin/com/bluedragonmc/server/command/JoinCommand.kt b/src/main/kotlin/com/bluedragonmc/server/command/JoinCommand.kt index 2fe0c600..9b8ec807 100644 --- a/src/main/kotlin/com/bluedragonmc/server/command/JoinCommand.kt +++ b/src/main/kotlin/com/bluedragonmc/server/command/JoinCommand.kt @@ -1,6 +1,5 @@ package com.bluedragonmc.server.command -import com.bluedragonmc.api.grpc.CommonTypes.GameType.GameTypeFieldSelector import com.bluedragonmc.api.grpc.gameType import com.bluedragonmc.server.api.Environment import net.kyori.adventure.text.Component @@ -18,7 +17,6 @@ class JoinCommand(name: String, private val usageString: String, vararg aliases: syntax(gameArgument) { Environment.queue.queue(player, gameType { this.name = get(gameArgument) - selectors += GameTypeFieldSelector.GAME_NAME }) }.requirePlayers() @@ -26,8 +24,6 @@ class JoinCommand(name: String, private val usageString: String, vararg aliases: Environment.queue.queue(player, gameType { this.name = get(gameArgument) this.mode = get(modeArgument) - selectors += GameTypeFieldSelector.GAME_NAME - selectors += GameTypeFieldSelector.GAME_MODE }) }.apply { requirePlayers() @@ -38,10 +34,7 @@ class JoinCommand(name: String, private val usageString: String, vararg aliases: Environment.queue.queue(player, gameType { this.name = get(gameArgument) this.mode = get(modeArgument) - this.mapName = get(mapArgument) - selectors += GameTypeFieldSelector.GAME_NAME - selectors += GameTypeFieldSelector.MAP_NAME - selectors += GameTypeFieldSelector.GAME_MODE + this.mapId = get(mapArgument) }) }.apply { requirePlayers() diff --git a/src/main/kotlin/com/bluedragonmc/server/command/LeaderboardCommand.kt b/src/main/kotlin/com/bluedragonmc/server/command/LeaderboardCommand.kt index e910d269..e91cbf29 100644 --- a/src/main/kotlin/com/bluedragonmc/server/command/LeaderboardCommand.kt +++ b/src/main/kotlin/com/bluedragonmc/server/command/LeaderboardCommand.kt @@ -2,8 +2,6 @@ package com.bluedragonmc.server.command import com.bluedragonmc.server.BRAND_COLOR_PRIMARY_1 import com.bluedragonmc.server.BRAND_COLOR_PRIMARY_2 -import com.bluedragonmc.server.Game -import com.bluedragonmc.server.lobby import com.bluedragonmc.server.module.database.StatisticsModule import com.bluedragonmc.server.module.database.StatisticsModule.OrderBy import com.bluedragonmc.server.service.Database @@ -11,7 +9,6 @@ import com.bluedragonmc.server.service.Permissions import com.bluedragonmc.server.utils.plus import kotlinx.coroutines.launch import net.kyori.adventure.text.Component -import net.minestom.server.entity.Player class LeaderboardCommand(name: String, usageString: String, vararg aliases: String) : BlueDragonCommand(name, aliases, block = { @@ -20,10 +17,8 @@ class LeaderboardCommand(name: String, usageString: String, vararg aliases: Stri syntax(keyArgument) { val key = get(keyArgument) - val game = if (sender is Player) Game.findGame(player) ?: lobby else lobby Database.IO.launch { - val leaderboard = game.getModule() - .rankPlayersByStatistic(key, OrderBy.DESC, limit = 10) + val leaderboard = StatisticsModule.rankPlayersByStatistic(key, OrderBy.DESC, limit = 10) leaderboard.forEach { (doc, value) -> val color = Permissions.getMetadata(doc.uuid).rankColor val formattedName = Component.text(doc.username, color) diff --git a/src/main/kotlin/com/bluedragonmc/server/command/LobbyCommand.kt b/src/main/kotlin/com/bluedragonmc/server/command/LobbyCommand.kt index a21fbf57..8b788896 100644 --- a/src/main/kotlin/com/bluedragonmc/server/command/LobbyCommand.kt +++ b/src/main/kotlin/com/bluedragonmc/server/command/LobbyCommand.kt @@ -1,14 +1,23 @@ package com.bluedragonmc.server.command -import com.bluedragonmc.server.service.Messaging -import com.bluedragonmc.server.lobby +import com.bluedragonmc.api.grpc.CommonTypes +import com.bluedragonmc.server.api.Environment +import com.bluedragonmc.server.Game import com.bluedragonmc.server.module.minigame.SpawnpointModule +import com.bluedragonmc.server.service.Messaging class LobbyCommand(name: String, vararg aliases: String?) : BlueDragonCommand(name, aliases, block = { requirePlayers() suspendSyntax { - if (player.instance == lobby.getInstance()) { - val pos = lobby.getModuleOrNull()?.spawnpointProvider?.getSpawnpoint(player) + val localLobby = Game.games.filter { it.data.name == Environment.defaultGameName }.randomOrNull() + + if (localLobby == null) { + Messaging.outgoing.addToQueue(player, CommonTypes.GameType.newBuilder().setName("Lobby").build()) + return@suspendSyntax + } + + if (player.instance == localLobby.getInstance()) { + val pos = localLobby.getModuleOrNull()?.spawnpointProvider?.getSpawnpoint(player) if (pos != null) { player.teleport(pos) } else { @@ -16,7 +25,7 @@ class LobbyCommand(name: String, vararg aliases: String?) : BlueDragonCommand(na } return@suspendSyntax } - lobby.addPlayer(player) + localLobby.addPlayer(player) // Remove the player from the queue when they go to the lobby Messaging.outgoing.removeFromQueue(player) } diff --git a/src/main/kotlin/com/bluedragonmc/server/impl/IncomingRPCHandlerImpl.kt b/src/main/kotlin/com/bluedragonmc/server/impl/IncomingRPCHandlerImpl.kt index 1b1217a8..d958265a 100644 --- a/src/main/kotlin/com/bluedragonmc/server/impl/IncomingRPCHandlerImpl.kt +++ b/src/main/kotlin/com/bluedragonmc/server/impl/IncomingRPCHandlerImpl.kt @@ -108,7 +108,7 @@ class IncomingRPCHandlerImpl(serverPort: Int) : IncomingRPCHandler { instances += GetInstancesResponseKt.runningInstance { this.gameState = game.rpcGameState this.instanceUuid = game.id - this.gameType = game.gameType + this.gameType = game.data.gameType game.getPlayers().forEach { player -> this.playerUuids.add(player.uuid.toString()) } diff --git a/src/main/kotlin/com/bluedragonmc/server/impl/MapProviders.kt b/src/main/kotlin/com/bluedragonmc/server/impl/MapProviders.kt new file mode 100644 index 00000000..3c874e74 --- /dev/null +++ b/src/main/kotlin/com/bluedragonmc/server/impl/MapProviders.kt @@ -0,0 +1,79 @@ +package com.bluedragonmc.server.impl + +import com.bluedragonmc.server.service.Maps +import kotlinx.coroutines.suspendCancellableCoroutine +import net.hollowcube.polar.PolarDataConverter +import net.hollowcube.polar.PolarLoader +import net.hollowcube.polar.PolarWorld +import net.hollowcube.polar.PolarWriter +import net.minestom.server.instance.InstanceContainer +import net.minestom.server.instance.anvil.AnvilLoader +import okhttp3.* +import okhttp3.RequestBody.Companion.toRequestBody +import okio.IOException +import org.slf4j.LoggerFactory +import java.net.URI +import java.nio.file.Paths +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private val client = OkHttpClient() + +class PolarMapProvider : Maps.MapProvider() { + + private val logger = LoggerFactory.getLogger(this::class.java) + + override suspend fun provideMap(source: Maps.MapSource): PolarLoader { + logger.info("Providing Polar map at ${source.url}") + val request = Request.Builder().url(source.url).build() + val response = client.newCall(request).await() + val body = response.body!! + logger.info("Got response of length ${body.contentLength()}") + if (body.contentLength() == 0L) { + logger.info("Map has no contents. Providing an empty map with default values.") + return PolarLoader(PolarWorld()) + } + return PolarLoader(response.body!!.byteStream()) + } + + override suspend fun saveMap(source: Maps.MapSource, instance: InstanceContainer) { + val loader = instance.chunkLoader as PolarLoader + instance.saveInstance() // update Polar's internal representation of the world (will not write any data to disk) + val mapBytes = PolarWriter.write(loader.world(), PolarDataConverter.NOOP) + val request = Request.Builder().url(source.url).method("POST", mapBytes.toRequestBody()).build() + client.newCall(request).await() + } +} + +class AnvilMapProvider : Maps.MapProvider() { + + private val logger = LoggerFactory.getLogger(this::class.java) + + override suspend fun provideMap(source: Maps.MapSource): AnvilLoader { + logger.info("Providing Anvil map at ${source.url}") + return AnvilLoader(Paths.get(URI.create(source.url))) + } + + override suspend fun saveMap(source: Maps.MapSource, instance: InstanceContainer) { + TODO() + } +} + +private suspend fun Call.await(): Response = + suspendCancellableCoroutine { cont -> + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (cont.isActive) { + cont.resumeWithException(e) + } + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response) + } + }) + + cont.invokeOnCancellation { + cancel() + } + } \ 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 84aefa51..a01472c2 100644 --- a/src/main/kotlin/com/bluedragonmc/server/impl/OutgoingRPCHandlerImpl.kt +++ b/src/main/kotlin/com/bluedragonmc/server/impl/OutgoingRPCHandlerImpl.kt @@ -1,6 +1,7 @@ package com.bluedragonmc.server.impl import com.bluedragonmc.api.grpc.* +import com.bluedragonmc.api.grpc.Map import com.bluedragonmc.api.grpc.Queue import com.bluedragonmc.server.Game import com.bluedragonmc.server.api.OutgoingRPCHandler @@ -54,7 +55,7 @@ class OutgoingRPCHandlerImpl(serverAddress: String, serverPort: Int) : OutgoingR override fun initialize(parent: Game, eventNode: EventNode): Unit = runBlocking { this@MessagingModule.parent = parent - Messaging.outgoing.initGame(parent.id, parent.gameType, parent.rpcGameState) + Messaging.outgoing.initGame(parent.id, parent.data.gameType, parent.rpcGameState) eventNode.listenAsync { event -> Messaging.outgoing.updateGameState(parent.id, event.game.rpcGameState) @@ -105,6 +106,7 @@ class OutgoingRPCHandlerImpl(serverAddress: String, serverPort: Int) : OutgoingR VelocityMessageServiceGrpcKt.VelocityMessageServiceCoroutineStub(channel) private val playerTrackerStub = PlayerTrackerGrpcKt.PlayerTrackerCoroutineStub(channel) private val queueStub = QueueServiceGrpcKt.QueueServiceCoroutineStub(channel) + private val mapStub = MapServiceGrpcKt.MapServiceCoroutineStub(channel) private val partyStub = PartyServiceGrpcKt.PartyServiceCoroutineStub(channel) private val jukeboxStub = JukeboxGrpcKt.JukeboxCoroutineStub(channel) @@ -155,15 +157,6 @@ class OutgoingRPCHandlerImpl(serverAddress: String, serverPort: Int) : OutgoingR ) } - override suspend fun checkRemoveInstance(gameId: String): Boolean { - return instanceSvcStub.withDeadlineAfter(5, TimeUnit.SECONDS).checkRemoveInstance( - ServerTracking.InstanceRemovedRequest.newBuilder() - .setServerName(serverName) - .setInstanceUuid(gameId) - .build() - ).shouldRemove - } - override suspend fun recordInstanceChange(player: Player, newGame: String) { playerTrackerStub.withDeadlineAfter(5, TimeUnit.SECONDS).playerInstanceChange( PlayerTrackerOuterClass.PlayerInstanceChangeRequest.newBuilder() @@ -199,6 +192,25 @@ class OutgoingRPCHandlerImpl(serverAddress: String, serverPort: Int) : OutgoingR ) } + override suspend fun getAvailableMaps(gameName: String?, gameMode: String?, whitelist: List?): Map.MapList { + val builder = com.bluedragonmc.api.grpc.Map.GetAvailableMapsRequest.newBuilder() + if (gameName != null) builder.gameName = gameName + if (gameMode != null) builder.gameMode = gameMode + if (whitelist != null) builder.whitelist = + com.bluedragonmc.api.grpc.Map.PlayerList.newBuilder().addAllPlayers(whitelist.map { it.toString() }).build() + return mapStub.withDeadlineAfter(5, TimeUnit.SECONDS).getAvailableMaps(builder.build()) + } + + override suspend fun updateMapConfig(mapId: String, configJson: String) { + mapStub.withDeadlineAfter(5, TimeUnit.SECONDS).updateMapConfig( + Map.UpdateMapConfigRequest.newBuilder() + .setMapId(mapId) + .setConfigJson(configJson) + .build() + ) + + } + override suspend fun addToQueue(player: Player, gameType: CommonTypes.GameType) { queueStub.withDeadlineAfter(5, TimeUnit.SECONDS).addToQueue( Queue.AddToQueueRequest.newBuilder() @@ -208,6 +220,18 @@ class OutgoingRPCHandlerImpl(serverAddress: String, serverPort: Int) : OutgoingR ) } + override suspend fun bulkAddToQueue(messages: List>) { + queueStub.withDeadlineAfter(5, TimeUnit.SECONDS).bulkAddToQueue( + Queue.BulkAddToQueueRequest.newBuilder() + .addAllRequests(messages.map { (player, gameType) -> + Queue.AddToQueueRequest.newBuilder() + .setPlayerUuid(player.uuid.toString()) + .setGameType(gameType) + .build() + }).build() + ) + } + override suspend fun removeFromQueue(player: Player) { queueStub.withDeadlineAfter(5, TimeUnit.SECONDS).removeFromQueue( Queue.RemoveFromQueueRequest.newBuilder() diff --git a/src/main/kotlin/com/bluedragonmc/server/queue/GameClassLoader.kt b/src/main/kotlin/com/bluedragonmc/server/queue/GameClassLoader.kt index 6b0c3f1e..40c5a169 100644 --- a/src/main/kotlin/com/bluedragonmc/server/queue/GameClassLoader.kt +++ b/src/main/kotlin/com/bluedragonmc/server/queue/GameClassLoader.kt @@ -8,31 +8,6 @@ import java.net.URLClassLoader */ class GameClassLoader(urls: Array?) : URLClassLoader(urls) { - override fun loadClass(name: String?, resolve: Boolean): Class<*> { - return loadClass0(name, resolve, search = true) - } - - private fun loadClass0(name: String?, resolve: Boolean, search: Boolean): Class<*> { - val ex: ClassNotFoundException - try { - return super.loadClass(name, resolve) - } catch (classNotFoundException: ClassNotFoundException) { - // Ignored - ex = classNotFoundException - } - if (!search) throw ex - - loaders.filter { it != this }.forEach { loader -> - try { - return loader.loadClass0(name, resolve, search = false) - } catch (_: ClassNotFoundException) { - // Ignored - } - } - - throw ex - } - init { loaders.add(this) } @@ -52,6 +27,6 @@ class GameClassLoader(urls: Array?) : URLClassLoader(urls) { } companion object { - internal val loaders = mutableSetOf() + internal val loaders = mutableListOf() } } \ No newline at end of file diff --git a/src/main/kotlin/com/bluedragonmc/server/queue/GameLoader.kt b/src/main/kotlin/com/bluedragonmc/server/queue/GameLoader.kt index 99a214d4..57c8b3e0 100644 --- a/src/main/kotlin/com/bluedragonmc/server/queue/GameLoader.kt +++ b/src/main/kotlin/com/bluedragonmc/server/queue/GameLoader.kt @@ -2,6 +2,7 @@ package com.bluedragonmc.server.queue import com.bluedragonmc.server.Game import com.bluedragonmc.server.bootstrap.GlobalTranslation +import com.bluedragonmc.server.game.GameData import org.slf4j.LoggerFactory import java.io.BufferedInputStream import java.nio.file.Path @@ -57,14 +58,10 @@ object GameLoader { error("No 'game.properties' file found in path: ${jarPath.pathString}") } - fun createNewGame(name: String, mapName: String?, mode: String?): Game { - val ctor = classes[name]?.kotlin?.primaryConstructor ?: error("Game class not found or improperly loaded") - if (ctor.parameters.isNotEmpty() && mapName.isNullOrBlank()) error("Map name is required by game, but not supplied") - return when(ctor.parameters.size) { - 0 -> ctor.call() - 1 -> ctor.call(mapName) - 2 -> ctor.call(mapName, mode) - else -> error("Unexpected constructor format: $ctor") - }.apply { init() } + fun createNewGame(data: GameData): Game { + val ctor = classes[data.name]?.kotlin?.primaryConstructor ?: error("Game class not found or improperly loaded") + val game = ctor.call(data) + game.init() + return game } } \ No newline at end of file diff --git a/src/main/kotlin/com/bluedragonmc/server/queue/IPCQueue.kt b/src/main/kotlin/com/bluedragonmc/server/queue/IPCQueue.kt index 7f7a0324..45aeded9 100644 --- a/src/main/kotlin/com/bluedragonmc/server/queue/IPCQueue.kt +++ b/src/main/kotlin/com/bluedragonmc/server/queue/IPCQueue.kt @@ -3,12 +3,12 @@ package com.bluedragonmc.server.queue import com.bluedragonmc.api.grpc.CommonTypes import com.bluedragonmc.api.grpc.GsClient import com.bluedragonmc.api.grpc.PlayerHolderOuterClass.SendPlayerRequest -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.Game +import com.bluedragonmc.server.game.GameData import com.bluedragonmc.server.module.instance.InstanceModule import com.bluedragonmc.server.service.Database +import com.bluedragonmc.server.service.Maps import com.bluedragonmc.server.service.Messaging import kotlinx.coroutines.launch import net.kyori.adventure.text.Component @@ -17,7 +17,6 @@ import net.minestom.server.MinecraftServer import net.minestom.server.entity.Player import net.minestom.server.network.ConnectionState import org.slf4j.LoggerFactory -import java.io.File import java.util.* object IPCQueue : Queue() { @@ -26,10 +25,6 @@ object IPCQueue : Queue() { private val queuedPlayers = mutableListOf() override fun queue(player: Player, gameType: CommonTypes.GameType) { - if (gameType.name == Environment.defaultGameName && gameType.mapName == null && gameType.mode == null) { - lobby.addPlayer(player) - return - } player.sendMessage(Component.translatable("queue.adding", NamedTextColor.DARK_GRAY)) Database.IO.launch { if (queuedPlayers.contains(player)) { @@ -40,28 +35,23 @@ object IPCQueue : Queue() { } } - override fun start() { - + override fun bulkEnqueue(requests: List>) { + requests.forEach { it.first.sendMessage(Component.translatable("queue.adding", NamedTextColor.DARK_GRAY)) } + Database.IO.launch { + Messaging.outgoing.bulkAddToQueue(requests) + } } - override fun getMaps(gameType: String): Array? { - val worldFolder = "worlds/$gameType" - val file = File(worldFolder) - if (!(file.exists() && file.isDirectory)) arrayOf() - return file.listFiles() - } + override fun start() { - override fun randomMap(gameType: String): String? = getMaps(gameType)?.randomOrNull()?.name + } - override fun createInstance(request: GsClient.CreateInstanceRequest): Game? { + override fun createInstance(request: GsClient.CreateInstanceRequest): Game { val start = System.nanoTime() - val map = if (request.gameType.hasMapName()) request.gameType.mapName else randomMap(request.gameType.name) - if (map == null) { - logger.error("An instance request for ${request.gameType.name} was received, but no map name was provided and a random map was not found.") - return null - } - val game = GameLoader.createNewGame(request.gameType.name, map, request.gameType.mode) - logger.info("Created '${request.gameType.name}' game on map '$map' and mode '${game.mode}' with id '${game.id}'. (${(System.nanoTime() - start) / 1_000_000}ms)") + val game = GameLoader.createNewGame(GameData(request.game, request.mapSource.let { + Maps.MapSource(it.mapId, it.mapUrl, it.mapFormat, it.mapConfig) + }, if (request.hasMode()) request.mode else null)) + logger.info("Created '${request.game}' game on map '${game.data.mapSource.id}' and mode '${game.data.mode}' with id '${game.id}'. (${(System.nanoTime() - start) / 1_000_000}ms)") // The service will soon be notified of this instance's creation // once the mandatory MessagingModule is initialized return game diff --git a/src/main/kotlin/com/bluedragonmc/server/queue/TestQueue.kt b/src/main/kotlin/com/bluedragonmc/server/queue/TestQueue.kt index 7fcdcf8a..ebf9351c 100644 --- a/src/main/kotlin/com/bluedragonmc/server/queue/TestQueue.kt +++ b/src/main/kotlin/com/bluedragonmc/server/queue/TestQueue.kt @@ -2,20 +2,20 @@ package com.bluedragonmc.server.queue import com.bluedragonmc.api.grpc.CommonTypes 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.game.GameData +import com.bluedragonmc.server.service.Maps +import com.bluedragonmc.server.service.Messaging import com.github.benmanes.caffeine.cache.Cache import com.github.benmanes.caffeine.cache.Caffeine +import kotlinx.coroutines.runBlocking import net.kyori.adventure.text.Component import net.kyori.adventure.text.event.HoverEvent import net.kyori.adventure.text.format.NamedTextColor import net.minestom.server.MinecraftServer import net.minestom.server.entity.Player import org.slf4j.LoggerFactory -import java.io.File import java.time.Duration -import kotlin.random.Random import kotlin.reflect.jvm.jvmName /** @@ -29,25 +29,23 @@ class TestQueue : Queue() { private val logger = LoggerFactory.getLogger(TestQueue::class.java) + private lateinit var maps: List + /** * Adds the player to the queue. * @param player The player to add to the queue. * @param gameType The game type which the player wants to join. */ override fun queue(player: Player, gameType: CommonTypes.GameType) { - if (gameType.name == Environment.defaultGameName && gameType.mapName.isEmpty() && gameType.mode.isEmpty()) { - lobby.addPlayer(player) - return - } if (queuedPlayers.getIfPresent(player) != null) { player.sendMessage(Component.translatable("queue.removing", NamedTextColor.RED)) queuedPlayers.invalidate(player) return } - if (gameType.mapName.isNotEmpty()) { - val mapExists = getMapNames(gameType.name).contains(gameType.mapName) + if (gameType.hasMapId()) { + val mapExists = + maps.any { map -> map matches gameType } if (!mapExists) { - player.sendMessage(Component.translatable("queue.error.no_map_found", NamedTextColor.RED)) return } } @@ -58,6 +56,10 @@ class TestQueue : Queue() { ) } + override fun bulkEnqueue(requests: List>) { + requests.forEach { (player, gameType) -> queue(player, gameType) } + } + /** * Adds a player to a game, regardless of their queue status. */ @@ -74,6 +76,11 @@ class TestQueue : Queue() { private var instanceStarting = false // only one instance is allowed to start per queue cycle override fun start() { + maps = runBlocking { + Messaging.outgoing.getAvailableMaps(null, null, null).mapsList.map { + Maps.MapSource(it.mapId, it.mapUrl, it.mapFormat, it.mapConfig) + } + } MinecraftServer.getSchedulerManager().buildTask { try { instanceStarting = false @@ -85,27 +92,31 @@ class TestQueue : Queue() { queuedPlayers.invalidate(player) return@forEach } - for (game in Game.games) { - if (game.name == gameType.name && - (!gameType.selectorsList.contains(CommonTypes.GameType.GameTypeFieldSelector.MAP_NAME) || gameType.mapName == game.mapName) - ) { - if (gameType.selectorsList.contains(CommonTypes.GameType.GameTypeFieldSelector.GAME_MODE) && game.mode != gameType.mode) { - continue - } - logger.info("Found a good game for ${player.username} to join") - queuedPlayers.invalidate(player) - join(player, game) - return@forEach - } + val game = Game.games.firstOrNull { + it.data.name == gameType.name + && (it.data.mapSource matches gameType) + && (it.data.mapSource.isPlayerAllowed(player.uuid)) + && (!gameType.hasMode() || gameType.mode == it.data.mode) + } + if (game != null) { + logger.info("Found a good game for ${player.username} to join") + join(player, game) + queuedPlayers.invalidate(player) + return@forEach } if (instanceStarting) return@forEach logger.info("Starting a new instance for ${player.username}") player.sendMessage(Component.translatable("queue.creating_instance", NamedTextColor.GREEN)) - val map = if (gameType.mapName.isNullOrEmpty()) randomMap(gameType.name) - else gameType.mapName - logger.info("Map chosen: $map") + val map = maps.filter { map -> map matches gameType && map.isPlayerAllowed(player.uuid) }.random() + logger.info("Map chosen: ${map.id}") try { - GameLoader.createNewGame(gameType.name, map ?: error("No map found for game"), gameType.mode) + GameLoader.createNewGame( + GameData( + gameType.name, + map, + gameType.mode + ) + ) instanceStarting = true } catch (e: Throwable) { e.printStackTrace() @@ -124,26 +135,4 @@ class TestQueue : Queue() { }.repeat(Duration.ofMillis(500)).schedule() } - override fun getMaps(gameType: String): Array? { - val worldFolder = "worlds/$gameType" - val file = File(worldFolder) - if (!(file.exists() && file.isDirectory)) arrayOf() - return file.listFiles() - } - - private fun getMapNames(gameType: String): ArrayList { - val maps = getMaps(gameType) ?: return arrayListOf() - val mapNames = ArrayList() - for (map in maps) { - mapNames.add(map.name) - } - return mapNames - } - - override fun randomMap(gameType: String): String? { - val allMaps = getMaps(gameType) - if (allMaps != null) return allMaps[Random.nextInt(allMaps.size)].name - return null - } - } \ No newline at end of file diff --git a/src/test/kotlin/com/bluedragonmc/server/module/config/serializer/PointSerializerTest.kt b/src/test/kotlin/com/bluedragonmc/server/module/config/serializer/PointSerializerTest.kt new file mode 100644 index 00000000..4e2f6d8c --- /dev/null +++ b/src/test/kotlin/com/bluedragonmc/server/module/config/serializer/PointSerializerTest.kt @@ -0,0 +1,43 @@ +package com.bluedragonmc.server.module.config.serializer + +import com.bluedragonmc.server.module.config.ConfigModule +import net.minestom.server.coordinate.BlockVec +import net.minestom.server.coordinate.Point +import net.minestom.server.coordinate.Pos +import net.minestom.server.coordinate.Vec +import org.junit.jupiter.api.Test +import org.spongepowered.configurate.BasicConfigurationNode +import org.spongepowered.configurate.ConfigurationNode +import org.spongepowered.configurate.gson.GsonConfigurationLoader +import kotlin.test.assertEquals + +class PointSerializerTest { + + private val builder = GsonConfigurationLoader + .builder() + .defaultOptions(ConfigModule.SERIALIZATION_OPTIONS) + + private fun node() = BasicConfigurationNode.root(ConfigModule.SERIALIZATION_OPTIONS) + private fun save(node: ConfigurationNode) = builder.buildAndSaveString(node) + private fun load(json: String) = builder.buildAndLoadString(json) + + @Test + fun testPointSerializer() { + val testNode = node() + testNode.node("pos").set(Pos(0.0, 64.0, 0.0)) + testNode.node("vec").set(Vec(0.0, 65.0, 0.0)) + testNode.node("blockvec").set(BlockVec(0.0, 66.0, 0.0)) + + // Save the node & parse it + val str = save(testNode) + val parsed = load(str) + + // When deserializing, everything is returned as a Pos + assertEquals(Pos(0.0, 64.0, 0.0), parsed.node("pos").get(Point::class.java)) + assertEquals(Pos(0.0, 65.0, 0.0), parsed.node("vec").get(Point::class.java)) + assertEquals(Pos(0.0, 66.0, 0.0), parsed.node("blockvec").get(Point::class.java)) + + // Make sure get() works + assertEquals(Pos(0.0, 66.0, 0.0), parsed.node("blockvec").get(Pos::class.java)) + } +} \ No newline at end of file